chore: add ui mode terminal (#21470)

This commit is contained in:
Pavel Feldman 2023-03-07 14:24:50 -08:00 committed by GitHub
parent 9e7abb2a76
commit e9f94f0346
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 172 additions and 101 deletions

View file

@ -36,6 +36,7 @@ class UIMode {
globalCleanup: (() => Promise<FullResult['status']>) | undefined; globalCleanup: (() => Promise<FullResult['status']>) | undefined;
private _watcher: FSWatcher | undefined; private _watcher: FSWatcher | undefined;
private _watchTestFile: string | undefined; private _watchTestFile: string | undefined;
private _originalStderr: (buffer: string | Uint8Array) => void;
constructor(config: FullConfigInternal) { constructor(config: FullConfigInternal) {
this._config = config; this._config = config;
@ -44,6 +45,15 @@ class UIMode {
p.retries = 0; p.retries = 0;
config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {}; config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {};
config._internal.configCLIOverrides.use.trace = 'on'; config._internal.configCLIOverrides.use.trace = 'on';
this._originalStderr = process.stderr.write.bind(process.stderr);
process.stdout.write = (chunk: string | Buffer) => {
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) });
return true;
};
process.stderr.write = (chunk: string | Buffer) => {
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) });
return true;
};
} }
async runGlobalSetup(): Promise<FullResult['status']> { async runGlobalSetup(): Promise<FullResult['status']> {
@ -78,6 +88,12 @@ class UIMode {
this._stopTests(); this._stopTests();
if (method === 'watch') if (method === 'watch')
this._watchFile(params.fileName); this._watchFile(params.fileName);
if (method === 'resizeTerminal') {
process.stdout.columns = params.cols;
process.stdout.rows = params.rows;
process.stderr.columns = params.cols;
process.stderr.columns = params.rows;
}
if (method === 'exit') if (method === 'exit')
exitPromise.resolve(); exitPromise.resolve();
}); });
@ -86,7 +102,7 @@ class UIMode {
private _dispatchEvent(message: any) { private _dispatchEvent(message: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
this._page.mainFrame().evaluateExpression(dispatchFuncSource, true, message).catch(e => console.log(e)); this._page.mainFrame().evaluateExpression(dispatchFuncSource, true, message).catch(e => this._originalStderr(String(e)));
} }
private async _listTests() { private async _listTests() {
@ -156,3 +172,15 @@ export async function runUIMode(config: FullConfigInternal): Promise<FullResult[
await uiMode.showUI(); await uiMode.showUI();
return await uiMode.globalCleanup?.() || 'passed'; return await uiMode.globalCleanup?.() || 'passed';
} }
type StdioPayload = {
type: 'stdout' | 'stderr';
text?: string;
buffer?: string;
};
function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): StdioPayload {
if (chunk instanceof Buffer)
return { type, buffer: chunk.toString('base64') };
return { type, text: chunk };
}

View file

@ -51,15 +51,12 @@
margin: 5px; margin: 5px;
} }
.watch-mode-sidebar .spacer { .status-line {
flex: auto;
}
.watch-mode-sidebar .status-line {
flex: none; flex: none;
border-top: 1px solid var(--vscode-panel-border);
line-height: 22px; line-height: 22px;
padding: 0 10px; padding: 0 10px;
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBar-background);
} }
.list-view-entry:not(.selected):not(.highlighted) .toolbar-button { .list-view-entry:not(.selected):not(.highlighted) .toolbar-button {

View file

@ -29,12 +29,21 @@ import { Toolbar } from '@web/components/toolbar';
import { toggleTheme } from '@web/theme'; import { toggleTheme } from '@web/theme';
import type { ContextEntry } from '../entries'; import type { ContextEntry } from '../entries';
import type * as trace from '@trace/trace'; import type * as trace from '@trace/trace';
import type { XtermDataSource } from '@web/components/xtermWrapper';
import { XtermWrapper } from '@web/components/xtermWrapper';
let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {}; let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {};
let updateStepsProgress: () => void = () => {}; let updateStepsProgress: () => void = () => {};
let runWatchedTests = () => {}; let runWatchedTests = () => {};
let runVisibleTests = () => {}; let runVisibleTests = () => {};
const xtermDataSource: XtermDataSource = {
pending: [],
clear: () => {},
write: data => xtermDataSource.pending.push(data),
resize: (cols: number, rows: number) => sendMessageNoReply('resizeTerminal', { cols, rows }),
};
export const WatchModeView: React.FC<{}> = ({ export const WatchModeView: React.FC<{}> = ({
}) => { }) => {
const [projectNames, setProjectNames] = React.useState<string[]>([]); const [projectNames, setProjectNames] = React.useState<string[]>([]);
@ -64,54 +73,31 @@ export const WatchModeView: React.FC<{}> = ({
setProjectNames([rootSuite.value?.suites[0].title]); setProjectNames([rootSuite.value?.suites[0].title]);
}, [projectNames, rootSuite]); }, [projectNames, rootSuite]);
return <SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}> return <div className='vbox'>
<TraceView testItem={selectedTestItem}></TraceView> <SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<div className='vbox watch-mode-sidebar'> <TraceView testItem={selectedTestItem}></TraceView>
<Toolbar> <div className='vbox watch-mode-sidebar'>
<div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div> <Toolbar>
<ToolbarButton icon='play' title='Run' onClick={runVisibleTests} disabled={isRunningTest}></ToolbarButton> <div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton> <ToolbarButton icon='play' title='Run' onClick={runVisibleTests} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='refresh' title='Reload' onClick={resetCollectingRootSuite} disabled={isRunningTest}></ToolbarButton> <ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton>
<div className='spacer'></div> <ToolbarButton icon='refresh' title='Reload' onClick={resetCollectingRootSuite} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='gear' title='Toggle color mode' toggled={settingsVisible} onClick={() => { setSettingsVisible(!settingsVisible); }}></ToolbarButton>
</Toolbar>
{ !settingsVisible && <TestList
projectNames={projectNames}
rootSuite={rootSuite}
isRunningTest={isRunningTest}
runTests={runTests}
onTestItemSelected={setSelectedTestItem} />}
{ settingsVisible && <div className='vbox'>
<div className='hbox' style={{ flex: 'none' }}>
<div className='section-title' style={{ marginTop: 10 }}>Projects</div>
<div className='spacer'></div> <div className='spacer'></div>
<ToolbarButton icon='close' title='Close settings' toggled={false} onClick={() => setSettingsVisible(false)}></ToolbarButton> <ToolbarButton icon='gear' title='Toggle color mode' toggled={settingsVisible} onClick={() => { setSettingsVisible(!settingsVisible); }}></ToolbarButton>
</div> </Toolbar>
{(rootSuite.value?.suites || []).map(suite => { { !settingsVisible && <TestList
return <div style={{ display: 'flex', alignItems: 'center', lineHeight: '24px' }}> projectNames={projectNames}
<input id={`project-${suite.title}`} type='checkbox' checked={projectNames.includes(suite.title)} onClick={() => { rootSuite={rootSuite}
const copy = [...projectNames]; isRunningTest={isRunningTest}
if (copy.includes(suite.title)) runTests={runTests}
copy.splice(copy.indexOf(suite.title), 1); onTestItemSelected={setSelectedTestItem} />}
else {settingsVisible && <SettingsView projectNames={projectNames} setProjectNames={setProjectNames} onClose={() => setSettingsVisible(false)}></SettingsView>}
copy.push(suite.title); </div>
setProjectNames(copy); </SplitView>
}} style={{ margin: '0 5px 0 10px' }} /> <div className='status-line'>
<label htmlFor={`project-${suite.title}`}>
{suite.title}
</label>
</div>;
})}
<div className='section-title'>Appearance</div>
<div style={{ marginLeft: 3 }}>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}>Toggle color mode</ToolbarButton>
</div>
</div>}
{isRunningTest && <div className='status-line'>
Running: {progress.total} tests | {progress.passed} passed | {progress.failed} failed Running: {progress.total} tests | {progress.passed} passed | {progress.failed} failed
</div>}
</div> </div>
</SplitView>; </div>;
}; };
export const TestList: React.FC<{ export const TestList: React.FC<{
@ -243,7 +229,40 @@ export const TestList: React.FC<{
expandedItems.set(treeItem.id, true); expandedItems.set(treeItem.id, true);
setExpandedItems(new Map(expandedItems)); setExpandedItems(new Map(expandedItems));
}} }}
noItemsMessage='No tests' />; noItemsMessage='No tests' />
</div>;
};
export const SettingsView: React.FC<{
projectNames: string[],
setProjectNames: (projectNames: string[]) => void,
onClose: () => void,
}> = ({ projectNames, setProjectNames, onClose }) => {
return <div className='vbox'>
<div className='hbox' style={{ flex: 'none' }}>
<div className='section-title' style={{ marginTop: 10 }}>Projects</div>
<div className='spacer'></div>
<ToolbarButton icon='close' title='Close settings' toggled={false} onClick={onClose}></ToolbarButton>
</div>
{projectNames.map(projectName => {
return <div style={{ display: 'flex', alignItems: 'center', lineHeight: '24px' }}>
<input id={`project-${projectName}`} type='checkbox' checked={projectNames.includes(projectName)} onClick={() => {
const copy = [...projectNames];
if (copy.includes(projectName))
copy.splice(copy.indexOf(projectName), 1);
else
copy.push(projectName);
setProjectNames(copy);
}} style={{ margin: '0 5px 0 10px' }} />
<label htmlFor={`project-${projectName}`}>
{projectName}
</label>
</div>;
})}
<div className='section-title'>Appearance</div>
<div style={{ marginLeft: 3 }}>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}>Toggle color mode</ToolbarButton>
</div>
</div>; </div>;
}; };
@ -274,7 +293,10 @@ export const TraceView: React.FC<{
})(); })();
}, [testItem, stepsProgress]); }, [testItem, stepsProgress]);
return <Workbench model={model}/>; const xterm = <XtermWrapper source={xtermDataSource}></XtermWrapper>;
return <Workbench model={model} output={xterm} rightToolbar={[
<ToolbarButton icon='trash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>,
]}/>;
}; };
declare global { declare global {
@ -325,10 +347,18 @@ const resetCollectingRootSuite = () => {
}; };
(window as any).dispatch = (message: any) => { (window as any).dispatch = (message: any) => {
if (message.method === 'fileChanged') if (message.method === 'fileChanged') {
runWatchedTests(); runWatchedTests();
else } else if (message.method === 'stdio') {
if (message.params.buffer) {
const data = atob(message.params.buffer);
xtermDataSource.write(data);
} else {
xtermDataSource.write(message.params.text);
}
} else {
receiver?.dispatch(message); receiver?.dispatch(message);
}
}; };
const sendMessage = async (method: string, params: any) => { const sendMessage = async (method: string, params: any) => {

View file

@ -103,7 +103,3 @@
.workbench .header .title { .workbench .header .title {
margin-left: 16px; margin-left: 16px;
} }
.workbench .spacer {
flex: auto;
}

View file

@ -26,17 +26,20 @@ import { NetworkTab } from './networkTab';
import { SnapshotTab } from './snapshotTab'; import { SnapshotTab } from './snapshotTab';
import { SourceTab } from './sourceTab'; import { SourceTab } from './sourceTab';
import { TabbedPane } from '@web/components/tabbedPane'; import { TabbedPane } from '@web/components/tabbedPane';
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { Timeline } from './timeline'; import { Timeline } from './timeline';
import './workbench.css'; import './workbench.css';
import { MetadataView } from './metadataView'; import { MetadataView } from './metadataView';
export const Workbench: React.FunctionComponent<{ export const Workbench: React.FunctionComponent<{
model?: MultiTraceModel, model?: MultiTraceModel,
}> = ({ model }) => { output?: React.ReactElement,
rightToolbar?: React.ReactElement[],
}> = ({ model, output, rightToolbar }) => {
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>(); const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>();
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>(); const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions'); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>('logs'); const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>(output ? 'output' : 'call');
const activeAction = model ? highlightedAction || selectedAction : undefined; const activeAction = model ? highlightedAction || selectedAction : undefined;
const { errors, warnings } = activeAction ? modelUtil.stats(activeAction) : { errors: 0, warnings: 0 }; const { errors, warnings } = activeAction ? modelUtil.stats(activeAction) : { errors: 0, warnings: 0 };
@ -44,14 +47,15 @@ export const Workbench: React.FunctionComponent<{
const networkCount = activeAction ? modelUtil.resourcesForAction(activeAction).length : 0; const networkCount = activeAction ? modelUtil.resourcesForAction(activeAction).length : 0;
const sdkLanguage = model?.sdkLanguage || 'javascript'; const sdkLanguage = model?.sdkLanguage || 'javascript';
const tabs = [ const tabs: TabbedPaneTabModel[] = [
{ id: 'logs', title: 'Call', count: 0, render: () => <CallTab action={activeAction} sdkLanguage={sdkLanguage} /> }, { id: 'call', title: 'Call', render: () => <CallTab action={activeAction} sdkLanguage={sdkLanguage} /> },
{ id: 'console', title: 'Console', count: consoleCount, render: () => <ConsoleTab action={activeAction} /> }, { id: 'console', title: 'Console', count: consoleCount, render: () => <ConsoleTab action={activeAction} /> },
{ id: 'network', title: 'Network', count: networkCount, render: () => <NetworkTab action={activeAction} /> }, { id: 'network', title: 'Network', count: networkCount, render: () => <NetworkTab action={activeAction} /> },
{ id: 'source', title: 'Source', count: 0, render: () => <SourceTab action={activeAction} /> },
]; ];
if (model?.hasSource) if (output)
tabs.push({ id: 'source', title: 'Source', count: 0, render: () => <SourceTab action={activeAction} /> }); tabs.unshift({ id: 'output', title: 'Output', component: output });
return <div className='vbox'> return <div className='vbox'>
<Timeline <Timeline
@ -59,38 +63,38 @@ export const Workbench: React.FunctionComponent<{
selectedAction={activeAction} selectedAction={activeAction}
onSelected={action => setSelectedAction(action)} onSelected={action => setSelectedAction(action)}
/> />
<SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}> <SplitView sidebarSize={output ? 250 : 350} orientation={output ? 'vertical' : 'horizontal'}>
<SplitView sidebarSize={300} orientation='vertical'> <SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<SnapshotTab action={activeAction} sdkLanguage={sdkLanguage} testIdAttributeName={model?.testIdAttributeName || 'data-testid'} /> <SnapshotTab action={activeAction} sdkLanguage={sdkLanguage} testIdAttributeName={model?.testIdAttributeName || 'data-testid'} />
<TabbedPane tabs={tabs} selectedTab={selectedPropertiesTab} setSelectedTab={setSelectedPropertiesTab}/> <TabbedPane tabs={
[
{
id: 'actions',
title: 'Actions',
count: 0,
component: <ActionList
sdkLanguage={sdkLanguage}
actions={model?.actions || []}
selectedAction={model ? selectedAction : undefined}
onSelected={action => {
setSelectedAction(action);
}}
onHighlighted={action => {
setHighlightedAction(action);
}}
revealConsole={() => setSelectedPropertiesTab('console')}
/>
},
{
id: 'metadata',
title: 'Metadata',
count: 0,
component: <MetadataView model={model}/>
},
]
} selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/>
</SplitView> </SplitView>
<TabbedPane tabs={ <TabbedPane tabs={tabs} selectedTab={selectedPropertiesTab} setSelectedTab={setSelectedPropertiesTab} rightToolbar={rightToolbar}/>
[
{
id: 'actions',
title: 'Actions',
count: 0,
component: <ActionList
sdkLanguage={sdkLanguage}
actions={model?.actions || []}
selectedAction={model ? selectedAction : undefined}
onSelected={action => {
setSelectedAction(action);
}}
onHighlighted={action => {
setHighlightedAction(action);
}}
revealConsole={() => setSelectedPropertiesTab('console')}
/>
},
{
id: 'metadata',
title: 'Metadata',
count: 0,
component: <MetadataView model={model}/>
},
]
} selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/>
</SplitView> </SplitView>
</div>; </div>;
}; };

View file

@ -97,6 +97,10 @@ svg {
position: relative; position: relative;
} }
.spacer {
flex: auto;
}
.codicon-check { .codicon-check {
color: var(--green); color: var(--green);
} }

View file

@ -161,7 +161,11 @@ const ListItemView: React.FC<{
ref={divRef} ref={divRef}
> >
{indent ? <div style={{ minWidth: indent * 16 }}></div> : undefined} {indent ? <div style={{ minWidth: indent * 16 }}></div> : undefined}
{hasIcons && <div className={'codicon ' + (icon || 'blank')} style={{ minWidth: 16, marginRight: 4 }} onClick={onIconClicked}></div>} {hasIcons && <div className={'codicon ' + (icon || 'blank')} style={{ minWidth: 16, marginRight: 4 }} onClick={e => {
e.stopPropagation();
e.preventDefault();
onIconClicked();
}}></div>}
{typeof children === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{children}</div> : children} {typeof children === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{children}</div> : children}
</div>; </div>;
}; };

View file

@ -45,6 +45,7 @@ export const TabbedPane: React.FunctionComponent<{
selected={selectedTab === tab.id} selected={selectedTab === tab.id}
onSelect={setSelectedTab} onSelect={setSelectedTab}
></TabbedPaneTab>)), ></TabbedPaneTab>)),
<div className='spacer'></div>,
...rightToolbar || [], ...rightToolbar || [],
]}</Toolbar> ]}</Toolbar>
{ {

View file

@ -19,10 +19,11 @@
box-shadow: var(--box-shadow); box-shadow: var(--box-shadow);
background-color: var(--vscode-sideBar-background); background-color: var(--vscode-sideBar-background);
color: var(--vscode-sideBarTitle-foreground); color: var(--vscode-sideBarTitle-foreground);
min-height: 32px; min-height: 35px;
align-items: center; align-items: center;
flex: none; flex: none;
z-index: 2; z-index: 2;
margin: 0 5px;
} }
.toolbar-linewrap { .toolbar-linewrap {
@ -31,7 +32,7 @@
} }
.toolbar input { .toolbar input {
padding: 0 10px; padding: 0 5px;
line-height: 24px; line-height: 24px;
outline: none; outline: none;
margin: 0 4px; margin: 0 4px;

View file

@ -20,13 +20,14 @@ import type { Terminal } from 'xterm';
import type { XtermModule } from './xtermModule'; import type { XtermModule } from './xtermModule';
import { isDarkTheme } from '@web/theme'; import { isDarkTheme } from '@web/theme';
export type XTermDataSource = { export type XtermDataSource = {
pending: (string | Uint8Array)[]; pending: (string | Uint8Array)[];
clear: () => void,
write: (data: string | Uint8Array) => void; write: (data: string | Uint8Array) => void;
resize: (cols: number, rows: number) => void; resize: (cols: number, rows: number) => void;
}; };
export const XTermWrapper: React.FC<{ source: XTermDataSource }> = ({ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
source source
}) => { }) => {
const xtermElement = React.createRef<HTMLDivElement>(); const xtermElement = React.createRef<HTMLDivElement>();
@ -55,8 +56,13 @@ export const XTermWrapper: React.FC<{ source: XTermDataSource }> = ({
for (const p of source.pending) for (const p of source.pending)
newTerminal.write(p); newTerminal.write(p);
source.write = (data => { source.write = (data => {
source.pending.push(data);
newTerminal.write(data); newTerminal.write(data);
}); });
source.clear = () => {
source.pending = [];
newTerminal.clear();
};
newTerminal.open(element); newTerminal.open(element);
fitAddon.fit(); fitAddon.fit();
setTerminal(newTerminal); setTerminal(newTerminal);