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 Feb 13, 2024
1 parent e973761 commit 1c877e9
Show file tree
Hide file tree
Showing 12 changed files with 487 additions and 79 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}/../playwright/examples/todomvc"
"${workspaceFolder}/../playwright/packages/html-reporter"
],
"outFiles": [
"${workspaceFolder}/out/**/*.js"
Expand Down
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
74 changes: 45 additions & 29 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,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) {
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, this._playwrightTest, this._testTree, watchData => this._watchesTriggered(watchData));
}

reusedBrowserForTest(): ReusedBrowser {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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);
Expand All @@ -423,7 +434,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 @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion src/listTests.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
25 changes: 24 additions & 1 deletion src/playwrightTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -99,6 +99,29 @@ export class PlaywrightTest {
}
}

async findRelatedTestFiles(config: TestConfig, files: string[]): Promise<ConfigFindRelatedTestFilesReport> {
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}`);
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
Loading

0 comments on commit 1c877e9

Please sign in to comment.