Skip to content

Commit

Permalink
feat: add watch support that tracks tests
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed Jan 27, 2024
1 parent c380e6f commit a21688c
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 72 deletions.
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"url": "https://github.com/microsoft/playwright-vscode/issues"
},
"engines": {
"vscode": "^1.73.0"
"vscode": "^1.78.0"
},
"categories": [
"Testing"
Expand Down Expand Up @@ -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",
Expand Down
70 changes: 43 additions & 27 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -42,8 +43,7 @@ type StepInfo = {
};

type TestRunInfo = {
selectedProjects: TestProject[];
isDebug: boolean;
project: TestProject;
request: vscodeTypes.TestRunRequest;
};

Expand Down Expand Up @@ -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<string>,
timer: NodeJS.Timeout,
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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);
Expand All @@ -436,7 +447,7 @@ export class Extension {

// Run tests with different configs sequentially, group by config.
const projectsToRunByModel = new Map<TestModel, TestProject[]>();
for (const project of selectedProjects) {
for (const project of projects) {
const projects = projectsToRunByModel.get(project.model) || [];
projects.push(project);
projectsToRunByModel.set(project.model, projects);
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 0 additions & 33 deletions src/reusedBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
92 changes: 92 additions & 0 deletions src/watchSupport.ts
Original file line number Diff line number Diff line change
@@ -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<Watch>();

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<Watch>();
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;
}
Loading

0 comments on commit a21688c

Please sign in to comment.