chore(ui): make ui-side drive things (#21398)

This commit is contained in:
Pavel Feldman 2023-03-04 15:05:41 -08:00 committed by GitHub
parent 0ebe090b8c
commit f0cd123a45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 169 additions and 105 deletions

View File

@ -40,17 +40,13 @@ export class TaskRunner<Context> {
this._tasks.push({ name, task }); this._tasks.push({ name, task });
} }
stop() { async run(context: Context, deadline: number, cancelPromise?: ManualPromise<void>): Promise<FullResult['status']> {
this._interrupted = true; const { status, cleanup } = await this.runDeferCleanup(context, deadline, cancelPromise);
}
async run(context: Context, deadline: number): Promise<FullResult['status']> {
const { status, cleanup } = await this.runDeferCleanup(context, deadline);
const teardownStatus = await cleanup(); const teardownStatus = await cleanup();
return status === 'passed' ? teardownStatus : status; return status === 'passed' ? teardownStatus : status;
} }
async runDeferCleanup(context: Context, deadline: number): Promise<{ status: FullResult['status'], cleanup: () => Promise<FullResult['status']> }> { async runDeferCleanup(context: Context, deadline: number, cancelPromise = new ManualPromise<void>()): Promise<{ status: FullResult['status'], cleanup: () => Promise<FullResult['status']> }> {
const sigintWatcher = new SigIntWatcher(); const sigintWatcher = new SigIntWatcher();
const timeoutWatcher = new TimeoutWatcher(deadline); const timeoutWatcher = new TimeoutWatcher(deadline);
const teardownRunner = new TaskRunner(this._reporter, this._globalTimeoutForError); const teardownRunner = new TaskRunner(this._reporter, this._globalTimeoutForError);
@ -87,6 +83,7 @@ export class TaskRunner<Context> {
await Promise.race([ await Promise.race([
taskLoop(), taskLoop(),
cancelPromise,
sigintWatcher.promise(), sigintWatcher.promise(),
timeoutWatcher.promise, timeoutWatcher.promise,
]); ]);
@ -98,7 +95,7 @@ export class TaskRunner<Context> {
this._interrupted = true; this._interrupted = true;
let status: FullResult['status'] = 'passed'; let status: FullResult['status'] = 'passed';
if (sigintWatcher.hadSignal()) { if (sigintWatcher.hadSignal() || cancelPromise?.isDone()) {
status = 'interrupted'; status = 'interrupted';
} else if (timeoutWatcher.timedOut()) { } else if (timeoutWatcher.timedOut()) {
this._reporter.onError?.({ message: `Timed out waiting ${this._globalTimeoutForError / 1000}s for the ${currentTaskName} to run` }); this._reporter.onError?.({ message: `Timed out waiting ${this._globalTimeoutForError / 1000}s for the ${currentTaskName} to run` });
@ -106,7 +103,7 @@ export class TaskRunner<Context> {
} else if (this._hasErrors) { } else if (this._hasErrors) {
status = 'failed'; status = 'failed';
} }
cancelPromise?.resolve();
const cleanup = () => teardownRunner.runDeferCleanup(context, deadline).then(r => r.status); const cleanup = () => teardownRunner.runDeferCleanup(context, deadline).then(r => r.status);
return { status, cleanup }; return { status, cleanup };
} }

View File

@ -14,112 +14,144 @@
* limitations under the License. * limitations under the License.
*/ */
import type { FullResult } from 'packages/playwright-test/reporter'; import { showTraceViewer } from 'playwright-core/lib/server';
import type { Page } from 'playwright-core/lib/server/page'; import type { Page } from 'playwright-core/lib/server/page';
import { showTraceViewer, serverSideCallMetadata } from 'playwright-core/lib/server'; import { ManualPromise } from 'playwright-core/lib/utils';
import { clearCompilationCache } from '../common/compilationCache'; import type { FullResult } from '../../reporter';
import { clearCompilationCache, dependenciesForTestFile } from '../common/compilationCache';
import type { FullConfigInternal } from '../common/types'; import type { FullConfigInternal } from '../common/types';
import ListReporter from '../reporters/list'; import ListReporter from '../reporters/list';
import { Multiplexer } from '../reporters/multiplexer'; import { Multiplexer } from '../reporters/multiplexer';
import { TeleReporterEmitter } from '../reporters/teleEmitter'; import { TeleReporterEmitter } from '../reporters/teleEmitter';
import { createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks';
import type { TaskRunnerState } from './tasks';
import { createReporter } from './reporters'; import { createReporter } from './reporters';
import type { TaskRunnerState } from './tasks';
import { createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks';
import { chokidar } from '../utilsBundle';
import type { FSWatcher } from 'chokidar';
export async function runUIMode(config: FullConfigInternal): Promise<FullResult['status']> { class UIMode {
// Reset the settings that don't apply to watch. private _config: FullConfigInternal;
private _page!: Page;
private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined;
globalCleanup: (() => Promise<FullResult['status']>) | undefined;
private _watcher: FSWatcher | undefined;
private _watchTestFile: string | undefined;
constructor(config: FullConfigInternal) {
this._config = config;
config._internal.passWithNoTests = true; config._internal.passWithNoTests = true;
for (const p of config.projects) for (const p of config.projects)
p.retries = 0; p.retries = 0;
config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {};
{ config._internal.configCLIOverrides.use.trace = 'on';
// Global setup.
const reporter = await createReporter(config, 'watch');
const taskRunner = createTaskRunnerForWatchSetup(config, reporter);
reporter.onConfigure(config);
const context: TaskRunnerState = {
config,
reporter,
phases: [],
};
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0);
if (status !== 'passed')
return await globalCleanup();
} }
// Show trace viewer. async runGlobalSetup(): Promise<FullResult['status']> {
const page = await showTraceViewer([], 'chromium', { watchMode: true }); const reporter = await createReporter(this._config, 'watch');
await page.mainFrame()._waitForFunctionExpression(serverSideCallMetadata(), '!!window.dispatch', false, undefined, { timeout: 0 }); const taskRunner = createTaskRunnerForWatchSetup(this._config, reporter);
{ reporter.onConfigure(this._config);
// List
const controller = new Controller(config, page);
const listReporter = new TeleReporterEmitter(message => controller!.send(message));
const reporter = new Multiplexer([listReporter]);
const taskRunner = createTaskRunnerForList(config, reporter);
const context: TaskRunnerState = { const context: TaskRunnerState = {
config, config: this._config,
reporter, reporter,
phases: [], phases: [],
}; };
reporter.onConfigure(config);
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0); const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0);
if (status !== 'passed') if (status !== 'passed') {
return await globalCleanup(); await globalCleanup();
return status;
}
this.globalCleanup = globalCleanup;
return status;
}
async showUI() {
this._page = await showTraceViewer([], 'chromium', { watchMode: true });
const exitPromise = new ManualPromise();
this._page.on('close', () => exitPromise.resolve());
this._page.exposeBinding('sendMessage', false, async (source, data) => {
const { method, params }: { method: string, params: any } = data;
if (method === 'list')
await this._listTests();
if (method === 'run')
await this._runTests(params.testIds);
if (method === 'stop')
this._stopTests();
if (method === 'watch')
this._watchFile(params.fileName);
if (method === 'exit')
exitPromise.resolve();
});
await exitPromise;
}
private _dispatchEvent(message: any) {
// eslint-disable-next-line no-console
this._page.mainFrame().evaluateExpression(dispatchFuncSource, true, message).catch(e => console.log(e));
}
private async _listTests() {
const listReporter = new TeleReporterEmitter(e => this._dispatchEvent(e));
const reporter = new Multiplexer([listReporter]);
const taskRunner = createTaskRunnerForList(this._config, reporter);
const context: TaskRunnerState = { config: this._config, reporter, phases: [] };
reporter.onConfigure(this._config);
await taskRunner.run(context, 0); await taskRunner.run(context, 0);
} }
await new Promise(() => {}); private async _runTests(testIds: string[]) {
// TODO: implement watch queue with the sigint watcher and global teardown. await this._stopTests();
return 'passed';
}
class Controller {
private _page: Page;
private _queue = Promise.resolve();
private _runReporter: TeleReporterEmitter;
constructor(config: FullConfigInternal, page: Page) {
this._page = page;
this._runReporter = new TeleReporterEmitter(message => this!.send(message));
this._page.exposeBinding('binding', false, (source, data) => {
const { method, params } = data;
if (method === 'run') {
const { location, testIds } = params;
if (location)
config._internal.cliArgs = [location];
if (testIds) {
const testIdSet = testIds ? new Set<string>(testIds) : null; const testIdSet = testIds ? new Set<string>(testIds) : null;
config._internal.testIdMatcher = id => !testIdSet || testIdSet.has(id); this._config._internal.testIdMatcher = id => !testIdSet || testIdSet.has(id);
}
this._queue = this._queue.then(() => runTests(config, this._runReporter)); const runReporter = new TeleReporterEmitter(e => this._dispatchEvent(e));
return this._queue; const reporter = new Multiplexer([new ListReporter(), runReporter]);
const taskRunner = createTaskRunnerForWatch(this._config, reporter);
const context: TaskRunnerState = { config: this._config, reporter, phases: [] };
clearCompilationCache();
reporter.onConfigure(this._config);
const stop = new ManualPromise();
const run = taskRunner.run(context, 0, stop).then(async status => {
await reporter.onExit({ status });
return status;
});
this._testRun = { run, stop };
await run;
this._testRun = undefined;
} }
private async _watchFile(fileName: string) {
if (this._watchTestFile === fileName)
return;
if (this._watcher)
await this._watcher.close();
this._watchTestFile = fileName;
if (!fileName)
return;
const files = [fileName, ...dependenciesForTestFile(fileName)];
this._watcher = chokidar.watch(files, { ignoreInitial: true }).on('all', async (event, file) => {
if (event !== 'add' && event !== 'change')
return;
this._dispatchEvent({ method: 'fileChanged', params: { fileName: file } });
}); });
} }
send(message: any) { private async _stopTests() {
const func = (message: any) => { this._testRun?.stop?.resolve();
(window as any).dispatch(message); await this._testRun?.run;
};
// eslint-disable-next-line no-console
this._page.mainFrame().evaluateExpression(String(func), true, message).catch(e => console.log(e));
} }
} }
async function runTests(config: FullConfigInternal, teleReporter: TeleReporterEmitter) { const dispatchFuncSource = String((message: any) => {
const reporter = new Multiplexer([new ListReporter(), teleReporter]); (window as any).dispatch(message);
config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {}; });
config._internal.configCLIOverrides.use.trace = 'on';
const taskRunner = createTaskRunnerForWatch(config, reporter); export async function runUIMode(config: FullConfigInternal): Promise<FullResult['status']> {
const context: TaskRunnerState = { const uiMode = new UIMode(config);
config, const status = await uiMode.runGlobalSetup();
reporter, if (status !== 'passed')
phases: [], return status;
}; await uiMode.showUI();
clearCompilationCache(); return await uiMode.globalCleanup?.() || 'passed';
reporter.onConfigure(config);
const status = await taskRunner.run(context, 0);
await reporter.onExit({ status });
} }

View File

@ -23,7 +23,11 @@
} }
.watch-mode-sidebar .toolbar-button:not([disabled]) .codicon-play { .watch-mode-sidebar .toolbar-button:not([disabled]) .codicon-play {
color: var(--vscode-testing-runAction); color: var(--vscode-debugIcon-restartForeground);
}
.watch-mode-sidebar .toolbar-button:not([disabled]) .codicon-debug-stop {
color: var(--vscode-debugIcon-stopForeground);
} }
.watch-mode-list-item { .watch-mode-list-item {

View File

@ -25,11 +25,13 @@ import { SplitView } from '@web/components/splitView';
import type { MultiTraceModel } from './modelUtil'; import type { MultiTraceModel } from './modelUtil';
import './watchMode.css'; import './watchMode.css';
import { ToolbarButton } from '@web/components/toolbarButton'; import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
let rootSuite: Suite | undefined; let rootSuite: Suite | undefined;
let updateList: () => void = () => {}; let updateList: () => void = () => {};
let updateProgress: () => void = () => {}; let updateProgress: () => void = () => {};
let runWatchedTests = () => {};
type Entry = { test?: TestCase, fileSuite: Suite }; type Entry = { test?: TestCase, fileSuite: Suite };
@ -47,8 +49,15 @@ export const WatchModeView: React.FC<{}> = ({
React.useEffect(() => { React.useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
sendMessageNoReply('list');
}, []); }, []);
React.useEffect(() => {
sendMessageNoReply('watch', {
fileName: selectedFileSuite?.location?.file || selectedTest?.location?.file
});
}, [selectedFileSuite, selectedTest]);
const selectedOrDefaultFileSuite = selectedFileSuite || rootSuite?.suites?.[0]?.suites?.[0]; const selectedOrDefaultFileSuite = selectedFileSuite || rootSuite?.suites?.[0]?.suites?.[0];
const tests: TestCase[] = []; const tests: TestCase[] = [];
const fileSuites: Suite[] = []; const fileSuites: Suite[] = [];
@ -93,8 +102,16 @@ export const WatchModeView: React.FC<{}> = ({
const runEntry = (entry: Entry) => { const runEntry = (entry: Entry) => {
expandedFiles.set(entry.fileSuite, true); expandedFiles.set(entry.fileSuite, true);
setSelectedTest(entry.test); setSelectedTest(entry.test);
runTests(collectTestIds(entry));
};
runWatchedTests = () => {
runTests(collectTestIds({ test: selectedTest, fileSuite: selectedFileSuite || selectedTest!.parent }));
};
const runTests = (testIds: string[] | undefined) => {
setIsRunningTest(true); setIsRunningTest(true);
runTests(entry.test ? entry.test.location.file + ':' + entry.test.location.line : entry.fileSuite.title, undefined).then(() => { sendMessage('run', { testIds }).then(() => {
setIsRunningTest(false); setIsRunningTest(false);
}); });
}; };
@ -103,20 +120,18 @@ export const WatchModeView: React.FC<{}> = ({
return <SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}> return <SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
<TraceView test={selectedTest} isRunningTest={isRunningTest}></TraceView> <TraceView test={selectedTest} isRunningTest={isRunningTest}></TraceView>
<div className='vbox watch-mode-sidebar'> <div className='vbox watch-mode-sidebar'>
<div style={{ flex: 'none', display: 'flex', padding: 4 }}> <Toolbar>
<input ref={inputRef} type='search' placeholder='Filter tests' spellCheck={false} value={filterText} <input ref={inputRef} type='search' placeholder='Filter tests' spellCheck={false} value={filterText}
onChange={e => { onChange={e => {
setFilterText(e.target.value); setFilterText(e.target.value);
}} }}
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter') { if (e.key === 'Enter')
setIsRunningTest(true); runTests([...visibleTestIds]);
runTests(undefined, [...visibleTestIds]).then(() => {
setIsRunningTest(false);
});
}
}}></input> }}></input>
</div> <ToolbarButton icon='play' title='Run' onClick={() => runTests([...visibleTestIds])} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton>
</Toolbar>
<ListView <ListView
items={[...entries.values()]} items={[...entries.values()]}
itemKey={(entry: Entry) => entry.test ? entry.test!.id : entry.fileSuite.title } itemKey={(entry: Entry) => entry.test ? entry.test!.id : entry.fileSuite.title }
@ -264,12 +279,28 @@ const receiver = new TeleReporterReceiver({
(window as any).dispatch = (message: any) => { (window as any).dispatch = (message: any) => {
if (message.method === 'fileChanged')
runWatchedTests();
else
receiver.dispatch(message); receiver.dispatch(message);
}; };
async function runTests(location: string | undefined, testIds: string[] | undefined): Promise<void> { const sendMessage = async (method: string, params: any) => {
await (window as any).binding({ await (window as any).sendMessage({ method, params });
method: 'run', };
params: { location, testIds }
const sendMessageNoReply = (method: string, params?: any) => {
sendMessage(method, params).catch((e: Error) => {
// eslint-disable-next-line no-console
console.error(e);
}); });
} };
const collectTestIds = (entry: Entry): string[] => {
const testIds: string[] = [];
if (entry.test)
testIds.push(entry.test.id);
else
entry.fileSuite.allTests().forEach(test => testIds.push(test.id));
return testIds;
};