diff --git a/packages/playwright-test/src/runner/taskRunner.ts b/packages/playwright-test/src/runner/taskRunner.ts index 2638c53fcf..fc077a4743 100644 --- a/packages/playwright-test/src/runner/taskRunner.ts +++ b/packages/playwright-test/src/runner/taskRunner.ts @@ -40,17 +40,13 @@ export class TaskRunner { this._tasks.push({ name, task }); } - stop() { - this._interrupted = true; - } - - async run(context: Context, deadline: number): Promise { - const { status, cleanup } = await this.runDeferCleanup(context, deadline); + async run(context: Context, deadline: number, cancelPromise?: ManualPromise): Promise { + const { status, cleanup } = await this.runDeferCleanup(context, deadline, cancelPromise); const teardownStatus = await cleanup(); return status === 'passed' ? teardownStatus : status; } - async runDeferCleanup(context: Context, deadline: number): Promise<{ status: FullResult['status'], cleanup: () => Promise }> { + async runDeferCleanup(context: Context, deadline: number, cancelPromise = new ManualPromise()): Promise<{ status: FullResult['status'], cleanup: () => Promise }> { const sigintWatcher = new SigIntWatcher(); const timeoutWatcher = new TimeoutWatcher(deadline); const teardownRunner = new TaskRunner(this._reporter, this._globalTimeoutForError); @@ -87,6 +83,7 @@ export class TaskRunner { await Promise.race([ taskLoop(), + cancelPromise, sigintWatcher.promise(), timeoutWatcher.promise, ]); @@ -98,7 +95,7 @@ export class TaskRunner { this._interrupted = true; let status: FullResult['status'] = 'passed'; - if (sigintWatcher.hadSignal()) { + if (sigintWatcher.hadSignal() || cancelPromise?.isDone()) { status = 'interrupted'; } else if (timeoutWatcher.timedOut()) { this._reporter.onError?.({ message: `Timed out waiting ${this._globalTimeoutForError / 1000}s for the ${currentTaskName} to run` }); @@ -106,7 +103,7 @@ export class TaskRunner { } else if (this._hasErrors) { status = 'failed'; } - + cancelPromise?.resolve(); const cleanup = () => teardownRunner.runDeferCleanup(context, deadline).then(r => r.status); return { status, cleanup }; } diff --git a/packages/playwright-test/src/runner/uiMode.ts b/packages/playwright-test/src/runner/uiMode.ts index 2e68f278de..44f0f2b329 100644 --- a/packages/playwright-test/src/runner/uiMode.ts +++ b/packages/playwright-test/src/runner/uiMode.ts @@ -14,112 +14,144 @@ * 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 { showTraceViewer, serverSideCallMetadata } from 'playwright-core/lib/server'; -import { clearCompilationCache } from '../common/compilationCache'; +import { ManualPromise } from 'playwright-core/lib/utils'; +import type { FullResult } from '../../reporter'; +import { clearCompilationCache, dependenciesForTestFile } from '../common/compilationCache'; import type { FullConfigInternal } from '../common/types'; import ListReporter from '../reporters/list'; import { Multiplexer } from '../reporters/multiplexer'; import { TeleReporterEmitter } from '../reporters/teleEmitter'; -import { createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks'; -import type { TaskRunnerState } from './tasks'; 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 { - // Reset the settings that don't apply to watch. - config._internal.passWithNoTests = true; - for (const p of config.projects) - p.retries = 0; +class UIMode { + private _config: FullConfigInternal; + private _page!: Page; + private _testRun: { run: Promise, stop: ManualPromise } | undefined; + globalCleanup: (() => Promise) | undefined; + private _watcher: FSWatcher | undefined; + private _watchTestFile: string | undefined; - { - // 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(); + constructor(config: FullConfigInternal) { + this._config = config; + config._internal.passWithNoTests = true; + for (const p of config.projects) + p.retries = 0; + config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {}; + config._internal.configCLIOverrides.use.trace = 'on'; } - // Show trace viewer. - const page = await showTraceViewer([], 'chromium', { watchMode: true }); - await page.mainFrame()._waitForFunctionExpression(serverSideCallMetadata(), '!!window.dispatch', false, undefined, { timeout: 0 }); - { - // 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); + async runGlobalSetup(): Promise { + const reporter = await createReporter(this._config, 'watch'); + const taskRunner = createTaskRunnerForWatchSetup(this._config, reporter); + reporter.onConfigure(this._config); const context: TaskRunnerState = { - config, + config: this._config, reporter, phases: [], }; - reporter.onConfigure(config); const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0); - if (status !== 'passed') - return await globalCleanup(); + if (status !== 'passed') { + 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 new Promise(() => {}); - // TODO: implement watch queue with the sigint watcher and global teardown. - return 'passed'; -} + private async _runTests(testIds: string[]) { + await this._stopTests(); + const testIdSet = testIds ? new Set(testIds) : null; + this._config._internal.testIdMatcher = id => !testIdSet || testIdSet.has(id); -class Controller { - private _page: Page; - private _queue = Promise.resolve(); - private _runReporter: TeleReporterEmitter; + const runReporter = new TeleReporterEmitter(e => this._dispatchEvent(e)); + 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; + } - 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(testIds) : null; - config._internal.testIdMatcher = id => !testIdSet || testIdSet.has(id); - } - this._queue = this._queue.then(() => runTests(config, this._runReporter)); - return this._queue; - } + 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) { - const func = (message: any) => { - (window as any).dispatch(message); - }; - // eslint-disable-next-line no-console - this._page.mainFrame().evaluateExpression(String(func), true, message).catch(e => console.log(e)); + private async _stopTests() { + this._testRun?.stop?.resolve(); + await this._testRun?.run; } } -async function runTests(config: FullConfigInternal, teleReporter: TeleReporterEmitter) { - const reporter = new Multiplexer([new ListReporter(), teleReporter]); - config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {}; - config._internal.configCLIOverrides.use.trace = 'on'; +const dispatchFuncSource = String((message: any) => { + (window as any).dispatch(message); +}); - const taskRunner = createTaskRunnerForWatch(config, reporter); - const context: TaskRunnerState = { - config, - reporter, - phases: [], - }; - clearCompilationCache(); - reporter.onConfigure(config); - const status = await taskRunner.run(context, 0); - await reporter.onExit({ status }); +export async function runUIMode(config: FullConfigInternal): Promise { + const uiMode = new UIMode(config); + const status = await uiMode.runGlobalSetup(); + if (status !== 'passed') + return status; + await uiMode.showUI(); + return await uiMode.globalCleanup?.() || 'passed'; } diff --git a/packages/trace-viewer/src/ui/watchMode.css b/packages/trace-viewer/src/ui/watchMode.css index 363fb486e0..c153a03bca 100644 --- a/packages/trace-viewer/src/ui/watchMode.css +++ b/packages/trace-viewer/src/ui/watchMode.css @@ -23,7 +23,11 @@ } .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 { diff --git a/packages/trace-viewer/src/ui/watchMode.tsx b/packages/trace-viewer/src/ui/watchMode.tsx index b4bda5b1ac..e1010df55b 100644 --- a/packages/trace-viewer/src/ui/watchMode.tsx +++ b/packages/trace-viewer/src/ui/watchMode.tsx @@ -25,11 +25,13 @@ import { SplitView } from '@web/components/splitView'; import type { MultiTraceModel } from './modelUtil'; import './watchMode.css'; import { ToolbarButton } from '@web/components/toolbarButton'; +import { Toolbar } from '@web/components/toolbar'; let rootSuite: Suite | undefined; let updateList: () => void = () => {}; let updateProgress: () => void = () => {}; +let runWatchedTests = () => {}; type Entry = { test?: TestCase, fileSuite: Suite }; @@ -47,8 +49,15 @@ export const WatchModeView: React.FC<{}> = ({ React.useEffect(() => { 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 tests: TestCase[] = []; const fileSuites: Suite[] = []; @@ -93,8 +102,16 @@ export const WatchModeView: React.FC<{}> = ({ const runEntry = (entry: Entry) => { expandedFiles.set(entry.fileSuite, true); setSelectedTest(entry.test); + runTests(collectTestIds(entry)); + }; + + runWatchedTests = () => { + runTests(collectTestIds({ test: selectedTest, fileSuite: selectedFileSuite || selectedTest!.parent })); + }; + + const runTests = (testIds: string[] | undefined) => { setIsRunningTest(true); - runTests(entry.test ? entry.test.location.file + ':' + entry.test.location.line : entry.fileSuite.title, undefined).then(() => { + sendMessage('run', { testIds }).then(() => { setIsRunningTest(false); }); }; @@ -103,20 +120,18 @@ export const WatchModeView: React.FC<{}> = ({ return
-
+ { setFilterText(e.target.value); }} onKeyDown={e => { - if (e.key === 'Enter') { - setIsRunningTest(true); - runTests(undefined, [...visibleTestIds]).then(() => { - setIsRunningTest(false); - }); - } + if (e.key === 'Enter') + runTests([...visibleTestIds]); }}> -
+ runTests([...visibleTestIds])} disabled={isRunningTest}> + sendMessageNoReply('stop')} disabled={!isRunningTest}> + entry.test ? entry.test!.id : entry.fileSuite.title } @@ -264,12 +279,28 @@ const receiver = new TeleReporterReceiver({ (window as any).dispatch = (message: any) => { - receiver.dispatch(message); + if (message.method === 'fileChanged') + runWatchedTests(); + else + receiver.dispatch(message); }; -async function runTests(location: string | undefined, testIds: string[] | undefined): Promise { - await (window as any).binding({ - method: 'run', - params: { location, testIds } +const sendMessage = async (method: string, params: any) => { + await (window as any).sendMessage({ method, params }); +}; + +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; +};