mirror of
https://github.com/microsoft/playwright.git
synced 2024-10-27 13:50:25 +03:00
chore: add ui mode terminal (#21470)
This commit is contained in:
parent
9e7abb2a76
commit
e9f94f0346
@ -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 };
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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) => {
|
||||
|
@ -103,7 +103,3 @@
|
||||
.workbench .header .title {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.workbench .spacer {
|
||||
flex: auto;
|
||||
}
|
||||
|
@ -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>;
|
||||
};
|
||||
|
@ -97,6 +97,10 @@ svg {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.codicon-check {
|
||||
color: var(--green);
|
||||
}
|
||||
|
@ -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>;
|
||||
};
|
||||
|
@ -45,6 +45,7 @@ export const TabbedPane: React.FunctionComponent<{
|
||||
selected={selectedTab === tab.id}
|
||||
onSelect={setSelectedTab}
|
||||
></TabbedPaneTab>)),
|
||||
<div className='spacer'></div>,
|
||||
...rightToolbar || [],
|
||||
]}</Toolbar>
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user