diff --git a/.vscode/launch.json b/.vscode/launch.json index 9d7b6a804..529b15021 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "runtimeExecutable": "${execPath}", "args": [ "--extensionDevelopmentPath=${workspaceFolder}", - "${workspaceFolder}/../playwright/examples/todomvc" + "${workspaceFolder}/../playwright/packages/html-reporter" ], "outFiles": [ "${workspaceFolder}/out/**/*.js" diff --git a/package-lock.json b/package-lock.json index ab37dbef6..4287067e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "@types/glob": "^8.0.0", "@types/node": "^18.11.9", "@types/stack-utils": "^2.0.1", - "@types/vscode": "1.73.0", + "@types/vscode": "1.78.0", "@types/which": "^2.0.1", "@types/ws": "^8.5.3", "@typescript-eslint/eslint-plugin": "^5.44.0", @@ -40,7 +40,7 @@ "typescript": "^4.9.3" }, "engines": { - "vscode": "^1.73.0" + "vscode": "^1.78.0" } }, "node_modules/@ampproject/remapping": { @@ -824,9 +824,9 @@ "dev": true }, "node_modules/@types/vscode": { - "version": "1.73.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.73.0.tgz", - "integrity": "sha512-FhkfF7V3fj7S3WqXu7AxFesBLO3uMkdCPJJPbwyZXezv2xJ6xBWHYM2CmkkbO8wT9Fr3KipwxGGOoQRrYq7mHg==", + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.78.0.tgz", + "integrity": "sha512-LJZIJpPvKJ0HVQDqfOy6W4sNKUBBwyDu1Bs8chHBZOe9MNuKTJtidgZ2bqjhmmWpUb0TIIqv47BFUcVmAsgaVA==", "dev": true }, "node_modules/@types/which": { @@ -5144,9 +5144,9 @@ "dev": true }, "@types/vscode": { - "version": "1.73.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.73.0.tgz", - "integrity": "sha512-FhkfF7V3fj7S3WqXu7AxFesBLO3uMkdCPJJPbwyZXezv2xJ6xBWHYM2CmkkbO8wT9Fr3KipwxGGOoQRrYq7mHg==", + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.78.0.tgz", + "integrity": "sha512-LJZIJpPvKJ0HVQDqfOy6W4sNKUBBwyDu1Bs8chHBZOe9MNuKTJtidgZ2bqjhmmWpUb0TIIqv47BFUcVmAsgaVA==", "dev": true }, "@types/which": { diff --git a/package.json b/package.json index 5916af9ad..0c5b96d9c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "url": "https://github.com/microsoft/playwright-vscode/issues" }, "engines": { - "vscode": "^1.73.0" + "vscode": "^1.78.0" }, "categories": [ "Testing" @@ -119,7 +119,7 @@ "@types/glob": "^8.0.0", "@types/node": "^18.11.9", "@types/stack-utils": "^2.0.1", - "@types/vscode": "1.73.0", + "@types/vscode": "1.78.0", "@types/which": "^2.0.1", "@types/ws": "^8.5.3", "@typescript-eslint/eslint-plugin": "^5.44.0", diff --git a/src/extension.ts b/src/extension.ts index bb1cd8d39..f44022069 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,7 @@ import { NodeJSNotFoundError, ansiToHtml } from './utils'; import * as vscodeTypes from './vscodeTypes'; import { WorkspaceChange, WorkspaceObserver } from './workspaceObserver'; import { TraceViewer } from './traceViewer'; +import { type Watch, WatchSupport } from './watchSupport'; const stackUtils = new StackUtils({ cwd: '/ensure_absolute_paths' @@ -42,9 +43,8 @@ type StepInfo = { }; type TestRunInfo = { - selectedProjects: TestProject[]; - isDebug: boolean; - request: vscodeTypes.TestRunRequest; + project: TestProject; + include: readonly vscodeTypes.TestItem[] | undefined; }; export async function activate(context: vscodeTypes.ExtensionContext) { @@ -73,13 +73,14 @@ export class Extension { private _activeStepDecorationType: vscodeTypes.TextEditorDecorationType; private _completedStepDecorationType: vscodeTypes.TextEditorDecorationType; private _playwrightTest: PlaywrightTest; - private _projectsScheduledToRun: TestProject[] | undefined; + private _itemsScheduledToRun: TestRunInfo[] | undefined; private _debugHighlight: DebugHighlight; private _isUnderTest: boolean; private _reusedBrowser: ReusedBrowser; private _traceViewer: TraceViewer; private _settingsModel: SettingsModel; private _settingsView!: SettingsView; + private _watchSupport: WatchSupport; private _filesPendingListTests: { files: Set, timer: NodeJS.Timeout, @@ -88,6 +89,7 @@ export class Extension { } | undefined; private _diagnostics: Record<'configErrors' | 'testErrors', vscodeTypes.DiagnosticCollection>; private _treeItemObserver: TreeItemObserver; + private _watchQueue = Promise.resolve(); constructor(vscode: vscodeTypes.VSCode) { this._vscode = vscode; @@ -125,6 +127,7 @@ export class Extension { configErrors: this._vscode.languages.createDiagnosticCollection('pw.configErrors.diagnostic'), }; this._treeItemObserver = new TreeItemObserver(this._vscode); + this._watchSupport = new WatchSupport(this._vscode, this._playwrightTest, this._testTree, watchData => this._watchesTriggered(watchData)); } reusedBrowserForTest(): ReusedBrowser { @@ -205,7 +208,7 @@ export class Extension { ...Object.values(this._diagnostics), this._treeItemObserver, ]; - await this._rebuildModel(true); + await this._rebuildModel(false); const fileSystemWatchers = [ // Glob parser does not supported nested group, hence multiple watchers. @@ -334,20 +337,22 @@ export class Extension { const keyPrefix = configFile + ':' + project.name; let runProfile = this._runProfiles.get(keyPrefix + ':run'); const projectTag = this._testTree.projectTag(project); + const isDefault = false; + const supportsContinuousRun = true; if (!runProfile) { - runProfile = this._testController.createRunProfile(`${projectPrefix}${folderName}${path.sep}${configName}`, this._vscode.TestRunProfileKind.Run, this._scheduleTestRunRequest.bind(this, configFile, project.name, false), false, projectTag); + runProfile = this._testController.createRunProfile(`${projectPrefix}${folderName}${path.sep}${configName}`, this._vscode.TestRunProfileKind.Run, this._scheduleTestRunRequest.bind(this, configFile, project.name, false), isDefault, projectTag, supportsContinuousRun); this._runProfiles.set(keyPrefix + ':run', runProfile); } let debugProfile = this._runProfiles.get(keyPrefix + ':debug'); if (!debugProfile) { - debugProfile = this._testController.createRunProfile(`${projectPrefix}${folderName}${path.sep}${configName}`, this._vscode.TestRunProfileKind.Debug, this._scheduleTestRunRequest.bind(this, configFile, project.name, true), false, projectTag); + debugProfile = this._testController.createRunProfile(`${projectPrefix}${folderName}${path.sep}${configName}`, this._vscode.TestRunProfileKind.Debug, this._scheduleTestRunRequest.bind(this, configFile, project.name, true), isDefault, projectTag, supportsContinuousRun); this._runProfiles.set(keyPrefix + ':debug', debugProfile); } } - private _scheduleTestRunRequest(configFile: string, projectName: string, isDebug: boolean, request: vscodeTypes.TestRunRequest) { + private _scheduleTestRunRequest(configFile: string, projectName: string, isDebug: boolean, request: vscodeTypes.TestRunRequest, cancellationToken?: vscodeTypes.CancellationToken) { // Never run tests concurrently. - if (this._testRun) + if (this._testRun && !request.continuous) return; // We can't dispose projects (and bind them to TestProject instances) because otherwise VS Code would forget its selection state. @@ -359,45 +364,51 @@ export class Extension { if (!project) return; + if (request.continuous) { + this._watchSupport.addToWatch(project, request.include, cancellationToken!); + return; + } + // VSCode will issue several test run requests (one per enabled run profile). Sometimes // these profiles belong to the same config and we only want to run tests once per config. // So we collect all requests and sort them out in the microtask. - if (!this._projectsScheduledToRun) { - this._projectsScheduledToRun = []; - this._projectsScheduledToRun.push(project); + if (!this._itemsScheduledToRun) { + this._itemsScheduledToRun = []; + this._itemsScheduledToRun.push({ project, include: request.include }); // Make sure to run tests outside of this function's control flow // so that we can create a new TestRunRequest and see its output. // TODO: remove once this is fixed in VSCode (1.78?) and we // can see test output without this hack. setTimeout(async () => { - const selectedProjects = this._projectsScheduledToRun; - this._projectsScheduledToRun = undefined; - if (selectedProjects) - await this._runMatchingTests({ selectedProjects, isDebug, request }); + const selectedItems = this._itemsScheduledToRun; + this._itemsScheduledToRun = undefined; + if (selectedItems) + await this._runMatchingTests(selectedItems, isDebug ? 'debug' : 'run'); }, 520); } else { // Subsequent requests will return right away. - this._projectsScheduledToRun.push(project); + this._itemsScheduledToRun.push({ project, include: request.include }); } } - private async _runMatchingTests(testRunInfo: TestRunInfo) { - const { selectedProjects, isDebug, request } = testRunInfo; - + private async _runMatchingTests(testRunInfos: TestRunInfo[], mode: 'run' | 'debug' | 'watch') { this._completedSteps.clear(); this._executionLinesChanged(); + const projects = testRunInfos.map(info => info.project); + const include = testRunInfos.map(info => info.include || []).flat(); + // Create a test run that potentially includes all the test items. // This allows running setup tests that are outside of the scope of the // selected test items. const rootItems: vscodeTypes.TestItem[] = []; this._testController.items.forEach(item => rootItems.push(item)); - const requestWithDeps = new this._vscode.TestRunRequest(rootItems, [], request.profile); + const requestWithDeps = new this._vscode.TestRunRequest(rootItems, [], undefined, mode === 'watch'); // Global errors are attributed to the first test item in the request. // If the request is global, find the first root test item (folder, file) that has // children. It will be reveal with an error. - let testItemForGlobalErrors = request.include?.[0]; + let testItemForGlobalErrors = include[0]; if (!testItemForGlobalErrors) { for (const rootItem of rootItems) { if (!rootItem.children.size) @@ -414,7 +425,7 @@ export class Extension { const enqueuedTests: vscodeTypes.TestItem[] = []; // Provisionally mark tests (not files and not suits) as enqueued to provide immediate feedback. - for (const item of request.include || []) { + for (const item of include) { for (const test of this._testTree.collectTestsInside(item)) { this._testRun.enqueued(test); enqueuedTests.push(test); @@ -423,7 +434,7 @@ export class Extension { // Run tests with different configs sequentially, group by config. const projectsToRunByModel = new Map(); - for (const project of selectedProjects) { + for (const project of projects) { const projects = projectsToRunByModel.get(project.model) || []; projects.push(project); projectsToRunByModel.set(project.model, projects); @@ -432,14 +443,14 @@ export class Extension { let ranSomeTests = false; try { for (const [model, projectsToRun] of projectsToRunByModel) { - const { projects, locations, parametrizedTestTitle } = this._narrowDownProjectsAndLocations(projectsToRun, request.include); + const { projects, locations, parametrizedTestTitle } = this._narrowDownProjectsAndLocations(projectsToRun, include); // Run if: // !locations => run all tests // locations.length => has matching items in project. if (locations && !locations.length) continue; ranSomeTests = true; - await this._runTest(this._testRun, testItemForGlobalErrors, new Set(), model, isDebug, projects, locations, parametrizedTestTitle, enqueuedTests.length === 1); + await this._runTest(this._testRun, testItemForGlobalErrors, new Set(), model, mode === 'debug', projects, locations, parametrizedTestTitle, enqueuedTests.length === 1); } } finally { this._activeSteps.clear(); @@ -455,8 +466,8 @@ located next to Run / Debug Tests toolbar buttons.`); } } - private _narrowDownProjectsAndLocations(projects: TestProject[], items: readonly vscodeTypes.TestItem[] | undefined): { projects: TestProject[], locations: string[] | null, parametrizedTestTitle: string | undefined } { - if (!items) + private _narrowDownProjectsAndLocations(projects: TestProject[], items: readonly vscodeTypes.TestItem[]): { projects: TestProject[], locations: string[] | null, parametrizedTestTitle: string | undefined } { + if (!items.length) return { projects, locations: null, parametrizedTestTitle: undefined }; let parametrizedTestTitle: string | undefined; @@ -507,7 +518,12 @@ located next to Run / Debug Tests toolbar buttons.`); await model.workspaceChanged(change); // Workspace change can be deferred, make sure editors are // decorated. - this._updateVisibleEditorItems(); + await this._updateVisibleEditorItems(); + await this._watchSupport.workspaceChanged(change); + } + + private _watchesTriggered(watches: Watch[]) { + this._watchQueue = this._watchQueue.then(() => this._runMatchingTests(watches, 'watch')); } private async _runTest( diff --git a/src/listTests.d.ts b/src/listTests.d.ts index 99032a0fd..5e5280e72 100644 --- a/src/listTests.d.ts +++ b/src/listTests.d.ts @@ -27,5 +27,11 @@ export type ProjectConfigWithFiles = { export type ConfigListFilesReport = { projects: ProjectConfigWithFiles[]; - error?: TestError + cliEntryPoint?: string; + error?: TestError; +}; + +export type ConfigFindRelatedTestFilesReport = { + testFiles: string[]; + errors?: TestError[]; }; diff --git a/src/playwrightTest.ts b/src/playwrightTest.ts index 0b0b01d7d..d15bbbaa3 100644 --- a/src/playwrightTest.ts +++ b/src/playwrightTest.ts @@ -17,7 +17,7 @@ import { spawn } from 'child_process'; import path from 'path'; import { debugSessionName } from './debugSessionName'; -import { ConfigListFilesReport } from './listTests'; +import { ConfigFindRelatedTestFilesReport, ConfigListFilesReport } from './listTests'; import type { TestError, Entry, StepBeginParams, StepEndParams, TestBeginParams, TestEndParams } from './oopReporter'; import { ReporterServer } from './reporterServer'; import { ReusedBrowser } from './reusedBrowser'; @@ -99,6 +99,29 @@ export class PlaywrightTest { } } + async findRelatedTestFiles(config: TestConfig, files: string[]): Promise { + const configFolder = path.dirname(config.configFile); + const configFile = path.basename(config.configFile); + const allArgs = [config.cli, 'find-related-test-files', '-c', configFile, ...files]; + { + // For tests. + this._log(`${escapeRegex(path.relative(config.workspaceFolder, configFolder))}> playwright find-related-test-files -c ${configFile}`); + } + try { + const output = await this._runNode(allArgs, configFolder); + const result = JSON.parse(output) as ConfigFindRelatedTestFilesReport; + return result; + } catch (error: any) { + return { + errors: [{ + location: { file: configFile, line: 0, column: 0 }, + message: error.message, + }], + testFiles: files, + }; + } + } + async runTests(config: TestConfig, projectNames: string[], locations: string[] | null, listener: TestListener, parametrizedTestTitle: string | undefined, token: vscodeTypes.CancellationToken) { const locationArg = locations ? locations : []; const args = projectNames.filter(Boolean).map(p => `--project=${p}`); diff --git a/src/reusedBrowser.ts b/src/reusedBrowser.ts index d0611129f..370bf544a 100644 --- a/src/reusedBrowser.ts +++ b/src/reusedBrowser.ts @@ -27,39 +27,6 @@ import { installBrowsers } from './installer'; import { WebSocketTransport } from './transport'; import { SettingsModel } from './settingsModel'; -export type Snapshot = { - browsers: BrowserSnapshot[]; -}; - -export type BrowserSnapshot = { - contexts: ContextSnapshot[]; -}; - -export type ContextSnapshot = { - pages: PageSnapshot[]; -}; - -export type PageSnapshot = { - url: string; -}; - -export type SourceHighlight = { - line: number; - type: 'running' | 'paused' | 'error'; -}; - -export type Source = { - isRecorded: boolean; - id: string; - label: string; - text: string; - language: string; - highlight: SourceHighlight[]; - revealLine?: number; - // used to group the language generators - group?: string; -}; - export class ReusedBrowser implements vscodeTypes.Disposable { private _vscode: vscodeTypes.VSCode; private _browserServerWS: string | undefined; diff --git a/src/watchSupport.ts b/src/watchSupport.ts new file mode 100644 index 000000000..a7b04c8b6 --- /dev/null +++ b/src/watchSupport.ts @@ -0,0 +1,115 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { WorkspaceChange } from './workspaceObserver'; +import type { TestProject } from './testModel'; +import type { PlaywrightTest, TestConfig } from './playwrightTest'; +import * as vscodeTypes from './vscodeTypes'; +import { MultiMap } from './multimap'; +import { TestTree } from './testTree'; + +export type Watch = { + testDirFsPath: string; + project: TestProject; + include: readonly vscodeTypes.TestItem[] | undefined; +}; + +export class WatchSupport { + private _watches = new Set(); + + constructor(private vscode: vscodeTypes.VSCode, private playwrightTest: PlaywrightTest, private testTree: TestTree, public onWatchesTriggered: (watches: Watch[]) => void) {} + + addToWatch(project: TestProject, include: readonly vscodeTypes.TestItem[] | undefined, cancellationToken: vscodeTypes.CancellationToken) { + const watch: Watch = { + testDirFsPath: this.vscode.Uri.file(project.testDir).fsPath, + project, + include, + }; + this._watches.add(watch); + + // Watch contract has a bug in 1.86 - when outer non-global watch is disabled, it assumes that inner watches are + // discarded as well without issuing the token cancelation. + for (const ri of include || []) { + for (const watch of this._watches) { + for (const wi of watch.include || []) { + if (isAncestorOf(ri, wi)) { + this._watches.delete(watch); + break; + } + } + } + } + + cancellationToken.onCancellationRequested(() => this._watches.delete(watch)); + } + + async workspaceChanged(change: WorkspaceChange) { + // Collapse watches in the same project to the outermost + const matchingWatches = new MultiMap(); + const configs = new Set(); + for (const watch of this._watches) + configs.add(watch.project.model.config); + + const relatedByConfig = new Map(); + for (const config of configs) { + const { testFiles } = await this.playwrightTest.findRelatedTestFiles(config, [...change.changed, ...change.deleted]); + relatedByConfig.set(config, testFiles.map(f => this.vscode.Uri.file(f).fsPath)); + } + + for (const watch of this._watches || []) { + const testFiles = relatedByConfig.get(watch.project.model.config); + if (!testFiles || !testFiles.length) + continue; + + for (const testFile of testFiles) { + if (!watch.include) { + // Everything is watched => add file. + matchingWatches.set(watch, this.testTree.getOrCreateFileItem(testFile)); + continue; + } + for (const include of watch.include) { + if (!include.uri) + continue; + // Folder is watched => add file. + if (testFile.startsWith(include.uri.fsPath + '/')) { + matchingWatches.set(watch, this.testTree.getOrCreateFileItem(testFile)); + continue; + } + // File or a test is watched, use that include as it might be more specific (test) + if (testFile === include.uri.fsPath) { + matchingWatches.set(watch, include); + continue; + } + } + } + } + if (matchingWatches.size) { + const watchesToRun: Watch[] = []; + for (const [watch, include] of matchingWatches) + watchesToRun.push({ ...watch, include }); + this.onWatchesTriggered(watchesToRun); + } + } +} + +function isAncestorOf(root: vscodeTypes.TestItem, descendent: vscodeTypes.TestItem) { + while (descendent.parent) { + if (descendent.parent === root) + return true; + descendent = descendent.parent; + } + return false; +} diff --git a/src/workspaceObserver.ts b/src/workspaceObserver.ts index 1f238465f..4742dc96b 100644 --- a/src/workspaceObserver.ts +++ b/src/workspaceObserver.ts @@ -62,7 +62,7 @@ export class WorkspaceObserver { } if (this._timeout) clearTimeout(this._timeout); - this._timeout = setTimeout(() => this._reportChange(), 500); + this._timeout = setTimeout(() => this._reportChange(), 50); return this._pendingChange; } diff --git a/tests/mock/vscode.ts b/tests/mock/vscode.ts index 27a9c0228..0cf92bd87 100644 --- a/tests/mock/vscode.ts +++ b/tests/mock/vscode.ts @@ -266,6 +266,12 @@ class TestRunProfile { return testRun; } + async watch(include?: TestItem[], exclude?: TestItem[]): Promise { + const request = new TestRunRequest(include, exclude, this, true); + await this.runHandler(request, request.token); + return request; + } + dispose() { this.runProfiles.splice(this.runProfiles.indexOf(this), 1); } @@ -277,11 +283,13 @@ export class TestRunRequest { include: TestItem[] | undefined; exclude: TestItem[] | undefined; profile: TestRunProfile | undefined; + continuous?: boolean; - constructor(include?: TestItem[], exclude?: TestItem[], profile?: TestRunProfile) { + constructor(include?: TestItem[], exclude?: TestItem[], profile?: TestRunProfile, continuous?: boolean) { this.include = include; this.exclude = exclude; this.profile = profile; + this.continuous = continuous; } } @@ -437,11 +445,16 @@ export class TestController { return [...this.allTestItems.values()].filter(t => label.exec(t.label)); } - async run(include?: TestItem[], exclude?: TestItem[]): Promise { + async run(include?: TestItem[], exclude?: TestItem[], continuous?: boolean): Promise { const profile = this.runProfiles.find(p => p.kind === this.vscode.TestRunProfileKind.Run)!; return profile.run(include, exclude); } + async watch(include?: TestItem[], exclude?: TestItem[], continuous?: boolean): Promise { + const profile = this.runProfiles.find(p => p.kind === this.vscode.TestRunProfileKind.Run)!; + return profile.watch(include, exclude); + } + async debug(include?: TestItem[], exclude?: TestItem[]): Promise { const profile = this.runProfiles.find(p => p.kind === this.vscode.TestRunProfileKind.Debug)!; return profile.run(include, exclude); diff --git a/tests/run-tests.spec.ts b/tests/run-tests.spec.ts index 9f79175e5..ac5c607de 100644 --- a/tests/run-tests.spec.ts +++ b/tests/run-tests.spec.ts @@ -339,7 +339,6 @@ test('should only create test run if folder belongs to context', async ({ activa const items = testController.findTestItems(/foo1/); await Promise.all(profiles.map(p => p.run(items))); expect(testRuns).toHaveLength(1); - expect(testRuns[0].request.profile).toBe(profiles[0]); expect(vscode.renderExecLog(' ')).toBe(` tests1> playwright list-files -c playwright.config.js diff --git a/tests/watch.spec.ts b/tests/watch.spec.ts new file mode 100644 index 000000000..439886b86 --- /dev/null +++ b/tests/watch.spec.ts @@ -0,0 +1,269 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestRun } from './mock/vscode'; +import { expect, test } from './utils'; + +test('should watch all tests', async ({ activate }) => { + const { vscode, testController, workspaceFolder } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test-1.spec.ts': ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `, + 'tests/test-2.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('should fail', async () => { expect(1).toBe(2); }); + `, + }); + + await testController.watch(); + const [testRun] = await Promise.all([ + new Promise(f => testController.onDidCreateTestRun(testRun => { + testRun.onDidEnd(() => f(testRun)); + })), + workspaceFolder.changeFile('tests/test-1.spec.ts', ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `), + ]); + + expect(testRun.renderLog()).toBe(` + tests > test-1.spec.ts > should pass [2:0] + enqueued + started + passed + `); + + expect(vscode.renderExecLog(' ')).toBe(` + > playwright list-files -c playwright.config.js + > playwright find-related-test-files -c playwright.config.js + > playwright test -c playwright.config.js tests/test-1.spec.ts + `); +}); + +test('should unwatch all tests', async ({ activate }) => { + const { vscode, testController, workspaceFolder } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test-1.spec.ts': ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `, + 'tests/test-2.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('should fail', async () => { expect(1).toBe(2); }); + `, + }); + + const watchRequest = await testController.watch(); + watchRequest.token.source.cancel(); + + const testRuns: TestRun[] = []; + testController.onDidCreateTestRun(testRun => { testRuns.push(testRun); }); + await workspaceFolder.changeFile('tests/test-1.spec.ts', ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `); + + // Workspace observer has setTimeout(0) for coalescing. + await new Promise(f => setTimeout(f, 500)); + + expect(testRuns).toHaveLength(0); + + expect(vscode.renderExecLog(' ')).toBe(` + > playwright list-files -c playwright.config.js + `); +}); + +test('should watch test file', async ({ activate }) => { + const { vscode, testController, workspaceFolder } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test-1.spec.ts': ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `, + 'tests/test-2.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('should fail', async () => { expect(1).toBe(2); }); + `, + }); + + const testItem2 = testController.findTestItems(/test-2/); + await testController.watch(testItem2); + + const [testRun] = await Promise.all([ + new Promise(f => testController.onDidCreateTestRun(testRun => { + testRun.onDidEnd(() => f(testRun)); + })), + workspaceFolder.changeFile('tests/test-1.spec.ts', ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `), + workspaceFolder.changeFile('tests/test-2.spec.ts', ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `), + ]); + + expect(testRun.renderLog()).toBe(` + tests > test-2.spec.ts > should pass [2:0] + enqueued + started + passed + `); + + expect(vscode.renderExecLog(' ')).toBe(` + > playwright list-files -c playwright.config.js + > playwright find-related-test-files -c playwright.config.js + > playwright test -c playwright.config.js tests/test-2.spec.ts + `); +}); + +test.skip('should watch tests via helper', async ({ activate }) => { + // This test requires nightly playwright. + const { vscode, testController, workspaceFolder } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/helper.ts': ` + export const foo = 42; + `, + 'tests/test.spec.ts': ` + import { test, expect } from '@playwright/test'; + import { foo } from './helper'; + test('should pass', async () => { + expect(foo).toBe(42); + }); + `, + }); + + await testController.watch(); + + const [testRun] = await Promise.all([ + new Promise(f => testController.onDidCreateTestRun(testRun => { + testRun.onDidEnd(() => f(testRun)); + })), + workspaceFolder.changeFile('tests/helper.ts', ` + export const foo = 43; + `), + ]); + + expect(testRun.renderLog()).toBe(` + tests > test.spec.ts > should pass [2:0] + enqueued + started + failed + `); + + expect(vscode.renderExecLog(' ')).toBe(` + > playwright list-files -c playwright.config.js + > playwright find-related-test-files -c playwright.config.js + > playwright test -c playwright.config.js tests/test.spec.ts + `); +}); + +test('should watch test in a file', async ({ activate }) => { + const { vscode, testController, workspaceFolder } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test.spec.ts': ` + import { test } from '@playwright/test'; + test('pass 1', async () => {}); + test('pass 2', async () => {}); + `, + }); + + await testController.expandTestItems(/test.spec/); + const testItems = testController.findTestItems(/pass 1/); + await testController.watch(testItems); + + const [testRun] = await Promise.all([ + new Promise(f => testController.onDidCreateTestRun(testRun => { + testRun.onDidEnd(() => f(testRun)); + })), + workspaceFolder.changeFile('tests/test.spec.ts', ` + import { test } from '@playwright/test'; + test('pass 1', async () => {}); + test('pass 2', async () => {}); + test('pass 3', async () => {}); + `), + ]); + + expect(testRun.renderLog()).toBe(` + tests > test.spec.ts > pass 1 [2:0] + enqueued + enqueued + started + passed + `); + + // first --list is for expand + // second --list is for workspace change + expect(vscode.renderExecLog(' ')).toBe(` + > playwright list-files -c playwright.config.js + > playwright test -c playwright.config.js --list tests/test.spec.ts + > playwright test -c playwright.config.js --list tests/test.spec.ts + > playwright find-related-test-files -c playwright.config.js + > playwright test -c playwright.config.js tests/test.spec.ts:3 + `); +}); + +test('should watch two tests in a file', async ({ activate }) => { + const { vscode, testController, workspaceFolder } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test.spec.ts': ` + import { test } from '@playwright/test'; + test('pass 1', async () => {}); + test('pass 2', async () => {}); + `, + }); + + await testController.expandTestItems(/test.spec/); + const testItems = testController.findTestItems(/pass/); + await testController.watch(testItems); + + const [testRun] = await Promise.all([ + new Promise(f => testController.onDidCreateTestRun(testRun => { + testRun.onDidEnd(() => f(testRun)); + })), + workspaceFolder.changeFile('tests/test.spec.ts', ` + import { test } from '@playwright/test'; + test('pass 1', async () => {}); + test('pass 2', async () => {}); + test('pass 3', async () => {}); + `), + ]); + + expect(testRun.renderLog()).toBe(` + tests > test.spec.ts > pass 1 [2:0] + enqueued + enqueued + started + passed + tests > test.spec.ts > pass 2 [3:0] + enqueued + enqueued + started + passed + `); + + // first --list is for expand + // second --list is for workspace change + expect(vscode.renderExecLog(' ')).toBe(` + > playwright list-files -c playwright.config.js + > playwright test -c playwright.config.js --list tests/test.spec.ts + > playwright test -c playwright.config.js --list tests/test.spec.ts + > playwright find-related-test-files -c playwright.config.js + > playwright test -c playwright.config.js tests/test.spec.ts:3 tests/test.spec.ts:4 + `); +});