mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore(ui): make ui-side drive things (#21398)
This commit is contained in:
parent
0ebe090b8c
commit
f0cd123a45
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user