From a21688c20db35aad67025554c6f9c7017bb981f2 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 25 Jan 2024 14:45:22 -0800 Subject: [PATCH] feat: add watch support that tracks tests --- package-lock.json | 16 +++---- package.json | 4 +- src/extension.ts | 70 ++++++++++++++++++------------ src/reusedBrowser.ts | 33 -------------- src/watchSupport.ts | 92 ++++++++++++++++++++++++++++++++++++++++ src/workspaceObserver.ts | 2 +- tests/run-tests.spec.ts | 1 - 7 files changed, 146 insertions(+), 72 deletions(-) create mode 100644 src/watchSupport.ts diff --git a/package-lock.json b/package-lock.json index 42a8f0fbf..af85e6769 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 9f0a94d44..faf4660aa 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 7148403e3..a6738114f 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,8 +43,7 @@ type StepInfo = { }; type TestRunInfo = { - selectedProjects: TestProject[]; - isDebug: boolean; + project: TestProject; request: vscodeTypes.TestRunRequest; }; @@ -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, watches => this._watchesTriggered(watches)); } reusedBrowserForTest(): ReusedBrowser { @@ -345,22 +348,24 @@ export class Extension { const keyPrefix = configFile + ':' + project.name; let runProfile = this._runProfiles.get(keyPrefix + ':run'); const projectTag = this._testTree.projectTag(project); + const isDefault = true; + 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), true, 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), true, 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); } usedProfiles.add(runProfile); usedProfiles.add(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. @@ -372,45 +377,51 @@ export class Extension { if (!project) return; + if (request.continuous) { + this._watchSupport.addToWatch(project, request, 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, request }); // 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, request }); } } - 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.request.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) @@ -427,7 +438,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); @@ -436,7 +447,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); @@ -445,14 +456,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(); @@ -468,8 +479,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; @@ -520,7 +531,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(); + this._watchSupport.workspaceChanged(change); + } + + private _watchesTriggered(watches: Watch[]) { + this._watchQueue = this._watchQueue.then(() => this._runMatchingTests(watches, 'watch')); } private async _runTest( 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..eb6c7fad5 --- /dev/null +++ b/src/watchSupport.ts @@ -0,0 +1,92 @@ +/** + * 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 { CancellationToken, TestRunRequest } from 'vscode'; +import type { WorkspaceChange } from './workspaceObserver'; +import type { TestProject } from './testModel'; +import * as vscodeTypes from './vscodeTypes'; + +export type Watch = { + testDirFsPath: string; + project: TestProject; + request: TestRunRequest; +}; + +export class WatchSupport { + private _watches = new Set(); + + constructor(private vscode: vscodeTypes.VSCode, public onWatchesTriggered: (watches: Watch[]) => void) {} + + addToWatch(project: TestProject, request: TestRunRequest, cancellationToken: CancellationToken) { + const watch: Watch = { + testDirFsPath: this.vscode.Uri.file(project.testDir).fsPath, + project, + request, + }; + this._watches.add(watch); + cancellationToken.onCancellationRequested(() => { + this._watches.delete(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. + if (!request.include) + return; + for (const include of request.include) { + for (const watch of this._watches) { + for (const i of watch.request.include || []) { + if (isAncestorOf(include, i)) { + this._watches.delete(watch); + break; + } + } + } + } + }); + } + + workspaceChanged(change: WorkspaceChange) { + // Collapse watches in the same project to the outermost + if (!this._watches) + return; + const watchesToRun = new Set(); + for (const entry of change.changed) { + for (const watch of this._watches) { + if (!watch.request.include) { + // Entire project is watched. + if (entry.startsWith(watch.project.testDir)) + watchesToRun.add(watch); + continue; + } + + for (const include of watch.request.include) { + if (!include.uri) + continue; + if (entry.startsWith(include.uri.fsPath)) + watchesToRun.add(watch); + } + } + } + if (watchesToRun.size) + 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..ccd99f4c0 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(), 0); return this._pendingChange; } 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