Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: run tests by id in server mode #451

Merged
merged 1 commit into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 12 additions & 62 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import * as reporterTypes from './upstream/reporter';
import { ReusedBrowser } from './reusedBrowser';
import { SettingsModel } from './settingsModel';
import { SettingsView } from './settingsView';
import { TestModel, TestModelCollection, TestProject } from './testModel';
import { TestModel, TestModelCollection } from './testModel';
import { TestTree } from './testTree';
import { NodeJSNotFoundError, ansiToHtml, getPlaywrightInfo } from './utils';
import * as vscodeTypes from './vscodeTypes';
Expand All @@ -42,12 +42,6 @@ type StepInfo = {
duration: number;
};

type TestRunInfo = {
model: TestModel;
mode: 'run' | 'debug' | 'watch';
include: readonly vscodeTypes.TestItem[] | undefined;
};

export async function activate(context: vscodeTypes.ExtensionContext) {
// Do not await, quickly run the extension, schedule work.
new Extension(require('vscode')).activate(context);
Expand Down Expand Up @@ -321,29 +315,24 @@ export class Extension implements RunHooks {
return;
}

for (const model of this._models.enabledModels()) {
const testRun: TestRunInfo = { model, include: request.include, mode: isDebug ? 'debug' : 'run' };
await this._runTests(testRun);
}
await this._runTests(request.include, isDebug ? 'debug' : 'run');
}

private async _runTests(runInfo: TestRunInfo) {
private async _runTests(include: readonly vscodeTypes.TestItem[] | undefined, mode: 'run' | 'debug' | 'watch') {
this._completedSteps.clear();
this._executionLinesChanged();

const include = runInfo.include || [];

// 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, [], undefined, runInfo.mode === 'watch');
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 = include[0];
let testItemForGlobalErrors = include?.[0];
if (!testItemForGlobalErrors) {
for (const rootItem of rootItems) {
if (!rootItem.children.size)
Expand All @@ -356,15 +345,11 @@ export class Extension implements RunHooks {
break;
}
}
const { projects, locations, parametrizedTestTitle } = this._narrowDownLocations(runInfo.model, include);
if (locations && !locations.length)
return;

this._testRun = this._testController.createTestRun(requestWithDeps);
const enqueuedTests: vscodeTypes.TestItem[] = [];

// Provisionally mark tests (not files and not suits) as enqueued to provide immediate feedback.
const toEnqueue = include.length ? include : rootItems;
const toEnqueue = include?.length ? include : rootItems;
for (const item of toEnqueue) {
for (const test of this._testTree.collectTestsInside(item)) {
this._testRun.enqueued(test);
Expand All @@ -373,7 +358,8 @@ export class Extension implements RunHooks {
}

try {
await this._runTest(this._testRun, testItemForGlobalErrors, new Set(), runInfo.model, runInfo.mode === 'debug', projects, locations, parametrizedTestTitle, enqueuedTests.length === 1);
for (const model of this._models.enabledModels())
await this._runTest(this._testRun, include ? [...include] : [], testItemForGlobalErrors, new Set(), model, mode === 'debug', enqueuedTests.length === 1);
} finally {
this._activeSteps.clear();
this._executionLinesChanged();
Expand All @@ -382,40 +368,6 @@ export class Extension implements RunHooks {
}
}

private _narrowDownLocations(model: TestModel, items: readonly vscodeTypes.TestItem[]): { projects: TestProject[], locations: string[] | null, parametrizedTestTitle: string | undefined } {
if (!items.length)
return { projects: model.enabledProjects(), locations: null, parametrizedTestTitle: undefined };

let parametrizedTestTitle: string | undefined;
// When we are given one item, check if it is parametrized (more than 1 item on that line).
// If it is parametrized, use label when running test.
if (items.length === 1) {
const test = items[0];
if (test.uri && test.range) {
let testsAtLocation = 0;
test.parent?.children.forEach(t => {
if (t.uri?.fsPath === test.uri?.fsPath && t.range?.start.line === test.range?.start.line)
++testsAtLocation;
});
if (testsAtLocation > 1)
parametrizedTestTitle = test.label;
}
}

const locations = new Set<string>();
for (const item of items) {
const itemFsPath = item.uri!.fsPath;
const enabledFiles = model.enabledFiles();
for (const file of enabledFiles) {
if (file === itemFsPath || file.startsWith(itemFsPath)) {
const line = item.range ? ':' + (item.range.start.line + 1) : '';
locations.add(item.uri!.fsPath + line);
}
}
}
return { projects: model.enabledProjects(), locations: [...locations], parametrizedTestTitle };
}

private async _resolveChildren(fileItem: vscodeTypes.TestItem | undefined): Promise<void> {
if (!fileItem)
return;
Expand All @@ -434,19 +386,17 @@ export class Extension implements RunHooks {
private _watchesTriggered(watches: Watch[]) {
this._watchQueue = this._watchQueue.then(async () => {
for (const watch of watches)
await this._runTests(watch);
await this._runTests(watch.include, 'watch');
});
}

private async _runTest(
testRun: vscodeTypes.TestRun,
items: vscodeTypes.TestItem[],
testItemForGlobalErrors: vscodeTypes.TestItem | undefined,
testFailures: Set<vscodeTypes.TestItem>,
model: TestModel,
isDebug: boolean,
projects: TestProject[],
locations: string[] | null,
parametrizedTestTitle: string | undefined,
enqueuedSingleTest: boolean) {
const testListener: reporterTypes.ReporterV2 = {
onBegin: (rootSuite: reporterTypes.Suite) => {
Expand Down Expand Up @@ -554,10 +504,10 @@ export class Extension implements RunHooks {
};

if (isDebug) {
await model.debugTests(projects, locations, testListener, parametrizedTestTitle, testRun.token);
await model.debugTests(items, testListener, testRun.token);
} else {
await this._traceViewer.willRunTests(model.config);
await model.runTests(projects, locations, testListener, parametrizedTestTitle, testRun.token);
await model.runTests(items, testListener, testRun.token);
}
}

Expand Down
103 changes: 72 additions & 31 deletions src/playwrightTestCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,31 @@ import { ReporterServer } from './reporterServer';
import { escapeRegex, findNode, pathSeparator, runNode } from './utils';
import * as vscodeTypes from './vscodeTypes';
import * as reporterTypes from './upstream/reporter';
import type { PlaywrightTestOptions, PlaywrightTestRunOptions, TestConfig } from './playwrightTestTypes';
import type { PlaywrightTestOptions, PlaywrightTestRunOptions } from './playwrightTestTypes';
import { debugSessionName } from './debugSessionName';
import type { TestModel } from './testModel';

export class PlaywrightTestCLI {
private _vscode: vscodeTypes.VSCode;
private _options: PlaywrightTestOptions;
private _config: TestConfig;
private _model: TestModel;

constructor(vscode: vscodeTypes.VSCode, config: TestConfig, options: PlaywrightTestOptions) {
constructor(vscode: vscodeTypes.VSCode, model: TestModel, options: PlaywrightTestOptions) {
this._vscode = vscode;
this._config = config;
this._model = model;
this._options = options;
}

reset() {
}

async listFiles(): Promise<ConfigListFilesReport> {
const configFolder = path.dirname(this._config.configFile);
const configFile = path.basename(this._config.configFile);
const allArgs = [this._config.cli, 'list-files', '-c', configFile];
const configFolder = path.dirname(this._model.config.configFile);
const configFile = path.basename(this._model.config.configFile);
const allArgs = [this._model.config.cli, 'list-files', '-c', configFile];
{
// For tests.
this._log(`${escapeRegex(path.relative(this._config.workspaceFolder, configFolder))}> playwright list-files -c ${configFile}`);
this._log(`${escapeRegex(path.relative(this._model.config.workspaceFolder, configFolder))}> playwright list-files -c ${configFile}`);
}
const output = await this._runNode(allArgs, configFolder);
const result = JSON.parse(output) as Partial<ConfigListFilesReport>;
Expand All @@ -61,12 +62,14 @@ export class PlaywrightTestCLI {
await this._innerSpawn(locations, args, {}, reporter, token);
}

async runTests(locations: string[], options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise<void> {
async runTests(items: vscodeTypes.TestItem[], options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise<void> {
const { locations, parametrizedTestTitle } = this._narrowDownLocations(items);
if (!locations)
return;
const args = [];
if (options.projects)
options.projects.forEach(p => args.push(`--project=${p}`));
if (options.grep)
args.push(`--grep=${escapeRegex(options.grep)}`);
this._model.enabledProjectsFilter().forEach(p => args.push(`--project=${p}`));
if (parametrizedTestTitle)
args.push(`--grep=${escapeRegex(parametrizedTestTitle)}`);
args.push('--repeat-each=1');
args.push('--retries=0');
if (options.headed)
Expand All @@ -85,20 +88,20 @@ export class PlaywrightTestCLI {
// Playwright will restart itself as child process in the ESM mode and won't inherit the 3/4 pipes.
// Always use ws transport to mitigate it.
const reporterServer = new ReporterServer(this._vscode);
const node = await findNode(this._vscode, this._config.workspaceFolder);
const configFolder = path.dirname(this._config.configFile);
const configFile = path.basename(this._config.configFile);
const node = await findNode(this._vscode, this._model.config.workspaceFolder);
const configFolder = path.dirname(this._model.config.configFile);
const configFile = path.basename(this._model.config.configFile);
const escapedLocations = locations.map(escapeRegex).sort();

{
// For tests.
const relativeLocations = locations.map(f => path.relative(configFolder, f)).map(escapeRegex).sort();
const printArgs = extraArgs.filter(a => !a.includes('--repeat-each') && !a.includes('--retries') && !a.includes('--workers') && !a.includes('--trace'));
this._log(`${escapeRegex(path.relative(this._config.workspaceFolder, configFolder))}> playwright test -c ${configFile}${printArgs.length ? ' ' + printArgs.join(' ') : ''}${relativeLocations.length ? ' ' + relativeLocations.join(' ') : ''}`);
this._log(`${escapeRegex(path.relative(this._model.config.workspaceFolder, configFolder))}> playwright test -c ${configFile}${printArgs.length ? ' ' + printArgs.join(' ') : ''}${relativeLocations.length ? ' ' + relativeLocations.join(' ') : ''}`);
}

const childProcess = spawn(node, [
this._config.cli,
this._model.config.cli,
'test',
'-c', configFile,
...extraArgs,
Expand Down Expand Up @@ -129,31 +132,35 @@ export class PlaywrightTestCLI {
await reporterServer.wireTestListener(reporter, token);
}

async debugTests(locations: string[], testDirs: string[], options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise<void> {
const configFolder = path.dirname(this._config.configFile);
const configFile = path.basename(this._config.configFile);
async debugTests(items: vscodeTypes.TestItem[], options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise<void> {
const configFolder = path.dirname(this._model.config.configFile);
const configFile = path.basename(this._model.config.configFile);
const { locations, parametrizedTestTitle } = this._narrowDownLocations(items);
if (!locations)
return;
const testDirs = this._model.enabledProjects().map(p => p.project.testDir);
const escapedLocations = locations.map(escapeRegex);
const args = ['test',
'-c', configFile,
...escapedLocations,
options.headed ? '--headed' : '',
...(options.projects || []).map(p => `--project=${p}`),
...this._model.enabledProjectsFilter().map(p => `--project=${p}`),
'--repeat-each', '1',
'--retries', '0',
'--timeout', '0',
'--workers', options.workers,
].filter(Boolean);
if (options.grep)
args.push(`--grep=${escapeRegex(options.grep)}`);
if (parametrizedTestTitle)
args.push(`--grep=${escapeRegex(parametrizedTestTitle)}`);

{
// For tests.
const relativeLocations = locations.map(f => path.relative(configFolder, f)).map(escapeRegex);
this._log(`${escapeRegex(path.relative(this._config.workspaceFolder, configFolder))}> debug -c ${configFile}${relativeLocations.length ? ' ' + relativeLocations.join(' ') : ''}`);
this._log(`${escapeRegex(path.relative(this._model.config.workspaceFolder, configFolder))}> debug -c ${configFile}${relativeLocations.length ? ' ' + relativeLocations.join(' ') : ''}`);
}

const reporterServer = new ReporterServer(this._vscode);
const testOptions = await this._options.runHooks.onWillRunTests(this._config, true);
const testOptions = await this._options.runHooks.onWillRunTests(this._model.config, true);
try {
await this._vscode.debug.startDebugging(undefined, {
type: 'pwa-node',
Expand All @@ -174,7 +181,7 @@ export class PlaywrightTestCLI {
PW_TEST_HTML_REPORT_OPEN: 'never',
PWDEBUG: 'console',
},
program: this._config.cli,
program: this._model.config.cli,
args,
});
await reporterServer.wireTestListener(reporter, token);
Expand All @@ -184,12 +191,12 @@ export class PlaywrightTestCLI {
}

async findRelatedTestFiles(files: string[]): Promise<ConfigFindRelatedTestFilesReport> {
const configFolder = path.dirname(this._config.configFile);
const configFile = path.basename(this._config.configFile);
const allArgs = [this._config.cli, 'find-related-test-files', '-c', configFile, ...files];
const configFolder = path.dirname(this._model.config.configFile);
const configFile = path.basename(this._model.config.configFile);
const allArgs = [this._model.config.cli, 'find-related-test-files', '-c', configFile, ...files];
{
// For tests.
this._log(`${escapeRegex(path.relative(this._config.workspaceFolder, configFolder))}> playwright find-related-test-files -c ${configFile}`);
this._log(`${escapeRegex(path.relative(this._model.config.workspaceFolder, configFolder))}> playwright find-related-test-files -c ${configFile}`);
}
try {
const output = await this._runNode(allArgs, configFolder);
Expand All @@ -213,4 +220,38 @@ export class PlaywrightTestCLI {
private _log(line: string) {
this._options.playwrightTestLog.push(line);
}

private _narrowDownLocations(items: vscodeTypes.TestItem[]): { locations: string[] | null, parametrizedTestTitle: string | undefined } {
if (!items.length)
return { locations: [], parametrizedTestTitle: undefined };

let parametrizedTestTitle: string | undefined;
// When we are given one item, check if it is parametrized (more than 1 item on that line).
// If it is parametrized, use label when running test.
if (items.length === 1) {
const test = items[0];
if (test.uri && test.range) {
let testsAtLocation = 0;
test.parent?.children.forEach(t => {
if (t.uri?.fsPath === test.uri?.fsPath && t.range?.start.line === test.range?.start.line)
++testsAtLocation;
});
if (testsAtLocation > 1)
parametrizedTestTitle = test.label;
}
}

const locations = new Set<string>();
for (const item of items) {
const itemFsPath = item.uri!.fsPath;
const enabledFiles = this._model.enabledFiles();
for (const file of enabledFiles) {
if (file === itemFsPath || file.startsWith(itemFsPath)) {
const line = item.range ? ':' + (item.range.start.line + 1) : '';
locations.add(item.uri!.fsPath + line);
}
}
}
return { locations: locations.size ? [...locations] : null, parametrizedTestTitle };
}
}
Loading