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;
private _watcher: FSWatcher | undefined;
private _watchTestFile: string | undefined;
private _originalStderr: (buffer: string | Uint8Array) => void;
constructor(config: FullConfigInternal) {
this._config = config;
@ -44,6 +45,15 @@ class UIMode {
p.retries = 0;
config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {};
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']> {
@ -78,6 +88,12 @@ class UIMode {
this._stopTests();
if (method === 'watch')
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')
exitPromise.resolve();
});
@ -86,7 +102,7 @@ class UIMode {
private _dispatchEvent(message: any) {
// 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() {
@ -156,3 +172,15 @@ export async function runUIMode(config: FullConfigInternal): Promise<FullResult[
await uiMode.showUI();
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;
}
.watch-mode-sidebar .spacer {
flex: auto;
}
.watch-mode-sidebar .status-line {
.status-line {
flex: none;
border-top: 1px solid var(--vscode-panel-border);
line-height: 22px;
padding: 0 10px;
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBar-background);
}
.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 type { ContextEntry } from '../entries';
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 updateStepsProgress: () => void = () => {};
let runWatchedTests = () => {};
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<{}> = ({
}) => {
const [projectNames, setProjectNames] = React.useState<string[]>([]);
@ -64,54 +73,31 @@ export const WatchModeView: React.FC<{}> = ({
setProjectNames([rootSuite.value?.suites[0].title]);
}, [projectNames, rootSuite]);
return <SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
<TraceView testItem={selectedTestItem}></TraceView>
<div className='vbox watch-mode-sidebar'>
<Toolbar>
<div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div>
<ToolbarButton icon='play' title='Run' onClick={runVisibleTests} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton>
<ToolbarButton icon='refresh' title='Reload' onClick={resetCollectingRootSuite} disabled={isRunningTest}></ToolbarButton>
<div className='spacer'></div>
<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>
return <div className='vbox'>
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<TraceView testItem={selectedTestItem}></TraceView>
<div className='vbox watch-mode-sidebar'>
<Toolbar>
<div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div>
<ToolbarButton icon='play' title='Run' onClick={runVisibleTests} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton>
<ToolbarButton icon='refresh' title='Reload' onClick={resetCollectingRootSuite} disabled={isRunningTest}></ToolbarButton>
<div className='spacer'></div>
<ToolbarButton icon='close' title='Close settings' toggled={false} onClick={() => setSettingsVisible(false)}></ToolbarButton>
</div>
{(rootSuite.value?.suites || []).map(suite => {
return <div style={{ display: 'flex', alignItems: 'center', lineHeight: '24px' }}>
<input id={`project-${suite.title}`} type='checkbox' checked={projectNames.includes(suite.title)} onClick={() => {
const copy = [...projectNames];
if (copy.includes(suite.title))
copy.splice(copy.indexOf(suite.title), 1);
else
copy.push(suite.title);
setProjectNames(copy);
}} style={{ margin: '0 5px 0 10px' }} />
<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'>
<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 && <SettingsView projectNames={projectNames} setProjectNames={setProjectNames} onClose={() => setSettingsVisible(false)}></SettingsView>}
</div>
</SplitView>
<div className='status-line'>
Running: {progress.total} tests | {progress.passed} passed | {progress.failed} failed
</div>}
</div>
</SplitView>;
</div>;
};
export const TestList: React.FC<{
@ -243,7 +229,40 @@ export const TestList: React.FC<{
expandedItems.set(treeItem.id, true);
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>;
};
@ -274,7 +293,10 @@ export const TraceView: React.FC<{
})();
}, [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 {
@ -325,10 +347,18 @@ const resetCollectingRootSuite = () => {
};
(window as any).dispatch = (message: any) => {
if (message.method === 'fileChanged')
if (message.method === 'fileChanged') {
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);
}
};
const sendMessage = async (method: string, params: any) => {

View File

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

View File

@ -26,17 +26,20 @@ import { NetworkTab } from './networkTab';
import { SnapshotTab } from './snapshotTab';
import { SourceTab } from './sourceTab';
import { TabbedPane } from '@web/components/tabbedPane';
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { Timeline } from './timeline';
import './workbench.css';
import { MetadataView } from './metadataView';
export const Workbench: React.FunctionComponent<{
model?: MultiTraceModel,
}> = ({ model }) => {
output?: React.ReactElement,
rightToolbar?: React.ReactElement[],
}> = ({ model, output, rightToolbar }) => {
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>();
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
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 { 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 sdkLanguage = model?.sdkLanguage || 'javascript';
const tabs = [
{ id: 'logs', title: 'Call', count: 0, render: () => <CallTab action={activeAction} sdkLanguage={sdkLanguage} /> },
const tabs: TabbedPaneTabModel[] = [
{ id: 'call', title: 'Call', render: () => <CallTab action={activeAction} sdkLanguage={sdkLanguage} /> },
{ id: 'console', title: 'Console', count: consoleCount, render: () => <ConsoleTab 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)
tabs.push({ id: 'source', title: 'Source', count: 0, render: () => <SourceTab action={activeAction} /> });
if (output)
tabs.unshift({ id: 'output', title: 'Output', component: output });
return <div className='vbox'>
<Timeline
@ -59,38 +63,38 @@ export const Workbench: React.FunctionComponent<{
selectedAction={activeAction}
onSelected={action => setSelectedAction(action)}
/>
<SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
<SplitView sidebarSize={300} orientation='vertical'>
<SplitView sidebarSize={output ? 250 : 350} orientation={output ? 'vertical' : 'horizontal'}>
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<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>
<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}/>
<TabbedPane tabs={tabs} selectedTab={selectedPropertiesTab} setSelectedTab={setSelectedPropertiesTab} rightToolbar={rightToolbar}/>
</SplitView>
</div>;
};

View File

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

View File

@ -161,7 +161,11 @@ const ListItemView: React.FC<{
ref={divRef}
>
{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}
</div>;
};

View File

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

View File

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

View File

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