diff --git a/src/extension.ts b/src/extension.ts index ae77b9217..81b0422f1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,20 +18,19 @@ import path from 'path'; import StackUtils from 'stack-utils'; import { DebugHighlight } from './debugHighlight'; import { installBrowsers, installPlaywright } from './installer'; -import { RunHooks, TestConfig, getPlaywrightInfo } from './playwrightTest'; import * as reporterTypes from './upstream/reporter'; import { ReusedBrowser } from './reusedBrowser'; import { SettingsModel } from './settingsModel'; import { SettingsView } from './settingsView'; import { TestModel, TestModelCollection, TestProject, projectFiles } from './testModel'; import { TestTree } from './testTree'; -import { NodeJSNotFoundError, ansiToHtml } from './utils'; +import { NodeJSNotFoundError, ansiToHtml, getPlaywrightInfo } from './utils'; import * as vscodeTypes from './vscodeTypes'; import { WorkspaceChange, WorkspaceObserver } from './workspaceObserver'; import { TraceViewer } from './traceViewer'; -import { TestServerController } from './testServerController'; import { type Watch, WatchSupport } from './watchSupport'; import { registerTerminalLinkProvider } from './terminalLinkProvider'; +import { RunHooks, TestConfig } from './playwrightTestTypes'; const stackUtils = new StackUtils({ cwd: '/ensure_absolute_paths' @@ -86,7 +85,6 @@ export class Extension implements RunHooks { } | undefined; private _diagnostics: vscodeTypes.DiagnosticCollection; private _treeItemObserver: TreeItemObserver; - private _testServerController: TestServerController; private _watchQueue = Promise.resolve(); private _runProfile: vscodeTypes.TestRunProfile; private _debugProfile: vscodeTypes.TestRunProfile; @@ -116,7 +114,6 @@ export class Extension implements RunHooks { this._models = new TestModelCollection(vscode, this._settingsModel); this._reusedBrowser = new ReusedBrowser(this._vscode, this._settingsModel, this._envProvider.bind(this)); this._traceViewer = new TraceViewer(this._vscode, this._settingsModel, this._envProvider.bind(this)); - this._testServerController = new TestServerController(this._vscode, this._envProvider.bind(this)); this._testController = vscode.tests.createTestController('playwright', 'Playwright'); this._testController.resolveHandler = item => this._resolveChildren(item); this._testController.refreshHandler = () => this._rebuildModels(true).then(() => {}); @@ -227,7 +224,6 @@ export class Extension implements RunHooks { this._reusedBrowser, this._diagnostics, this._treeItemObserver, - this._testServerController, registerTerminalLinkProvider(this._vscode), ]; const fileSystemWatchers = [ @@ -298,7 +294,6 @@ export class Extension implements RunHooks { settingsModel: this._settingsModel, runHooks: this, isUnderTest: this._isUnderTest, - testServerController: this._testServerController, envProvider: this._envProvider.bind(this), }); await this._models.addModel(model); diff --git a/src/playwrightTest.ts b/src/playwrightTest.ts deleted file mode 100644 index 4586b7764..000000000 --- a/src/playwrightTest.ts +++ /dev/null @@ -1,439 +0,0 @@ -/** - * 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 { spawn } from 'child_process'; -import path from 'path'; -import { debugSessionName } from './debugSessionName'; -import { ConfigFindRelatedTestFilesReport, ConfigListFilesReport } from './listTests'; -import { ReporterServer } from './reporterServer'; -import { findNode, spawnAsync } from './utils'; -import * as vscodeTypes from './vscodeTypes'; -import { SettingsModel } from './settingsModel'; -import { TestServerController } from './testServerController'; -import * as reporterTypes from './upstream/reporter'; -import { TeleReporterReceiver } from './upstream/teleReceiver'; -import { TestServerInterface } from './upstream/testServerInterface'; - -export type TestConfig = { - workspaceFolder: string; - configFile: string; - cli: string; - version: number; - testIdAttributeName?: string; -}; - -const pathSeparator = process.platform === 'win32' ? ';' : ':'; - -type AllRunOptions = Parameters[0]; -export type PlaywrightTestRunOptions = Pick; - -export interface RunHooks { - onWillRunTests(config: TestConfig, debug: boolean): Promise<{ connectWsEndpoint?: string }>; - onDidRunTests(debug: boolean): Promise; -} - -export type PlaywrightTestOptions = { - configFile: string; - settingsModel: SettingsModel; - runHooks: RunHooks; - isUnderTest: boolean; - testServerController: TestServerController; - playwrightTestLog: string[]; - envProvider: () => NodeJS.ProcessEnv; -}; - -export class PlaywrightTest { - private _vscode: vscodeTypes.VSCode; - private _options: PlaywrightTestOptions; - - constructor(vscode: vscodeTypes.VSCode, options: PlaywrightTestOptions) { - this._vscode = vscode; - this._options = options; - } - - reset() { - this._options.testServerController.disposeTestServerFor(this._options.configFile); - } - - async listFiles(config: TestConfig): Promise { - try { - let result: ConfigListFilesReport; - if (this._useTestServer(config)) - result = await this._listFilesServer(config); - else - result = await this._listFilesCLI(config); - for (const project of result.projects) - project.files = project.files.map(f => this._vscode.Uri.file(f).fsPath); - if (result.error?.location) - result.error.location.file = this._vscode.Uri.file(result.error.location.file).fsPath; - return result; - } catch (error: any) { - return { - error: { - location: { file: config.configFile, line: 0, column: 0 }, - message: error.message, - }, - projects: [], - }; - } - } - - private async _listFilesCLI(config: TestConfig): Promise { - const configFolder = path.dirname(config.configFile); - const configFile = path.basename(config.configFile); - const allArgs = [config.cli, 'list-files', '-c', configFile]; - { - // For tests. - this._log(`${escapeRegex(path.relative(config.workspaceFolder, configFolder))}> playwright list-files -c ${configFile}`); - } - const output = await this._runNode(allArgs, configFolder); - const result = JSON.parse(output) as Partial; - return { - // list-files does not return `projects: []` if there is an error. - projects: [], - ...result, - }; - } - - private async _listFilesServer(config: TestConfig): Promise { - const testServer = await this._options.testServerController.testServerFor(config); - if (!testServer) - throw new Error('Internal error: unable to connect to the test server'); - - const result: ConfigListFilesReport = { - projects: [], - }; - - // TODO: remove ConfigListFilesReport and report suite directly once CLI is deprecated. - const { report } = await testServer.listFiles({}); - const teleReceiver = new TeleReporterReceiver({ - onBegin(rootSuite) { - for (const projectSuite of rootSuite.suites) { - const project = projectSuite.project()!; - const files: string[] = []; - result.projects.push({ - name: project.name, - testDir: project.testDir, - use: project.use || {}, - files, - }); - for (const fileSuite of projectSuite.suites) - files.push(fileSuite.location!.file); - } - }, - onError(error) { - result.error = error; - }, - }, { - mergeProjects: true, - mergeTestCases: true, - resolvePath: (rootDir: string, relativePath: string) => this._vscode.Uri.file(path.join(rootDir, relativePath)).fsPath, - }); - for (const message of report) - teleReceiver.dispatch(message); - return result; - } - - async runTests(config: TestConfig, projectNames: string[], locations: string[] | null, listener: reporterTypes.ReporterV2, parametrizedTestTitle: string | undefined, token: vscodeTypes.CancellationToken) { - const locationArg = locations ? locations : []; - if (token?.isCancellationRequested) - return; - const externalOptions = await this._options.runHooks.onWillRunTests(config, false); - const showBrowser = this._options.settingsModel.showBrowser.get() && !!externalOptions.connectWsEndpoint; - - let trace: 'on' | 'off' | undefined; - if (this._options.settingsModel.showTrace.get()) - trace = 'on'; - // "Show browser" mode forces context reuse that survives over multiple test runs. - // Playwright Test sets up `tracesDir` inside the `test-results` folder, so it will be removed between runs. - // When context is reused, its ongoing tracing will fail with ENOENT because trace files - // were suddenly removed. So we disable tracing in this case. - if (this._options.settingsModel.showBrowser.get()) - trace = 'off'; - - const options: PlaywrightTestRunOptions = { - grep: parametrizedTestTitle, - projects: projectNames.length ? projectNames.filter(Boolean) : undefined, - headed: showBrowser && !this._options.isUnderTest, - workers: showBrowser ? 1 : undefined, - trace, - reuseContext: showBrowser, - connectWsEndpoint: showBrowser ? externalOptions.connectWsEndpoint : undefined, - }; - - try { - if (token?.isCancellationRequested) - return; - await this._test(config, locationArg, 'test', options, listener, token); - } finally { - await this._options.runHooks.onDidRunTests(false); - } - } - - async listTests(config: TestConfig, files: string[]): Promise<{ rootSuite: reporterTypes.Suite, errors: reporterTypes.TestError[] }> { - const errors: reporterTypes.TestError[] = []; - let rootSuite: reporterTypes.Suite | undefined; - await this._test(config, files, 'list', {}, { - onBegin: (suite: reporterTypes.Suite) => { - rootSuite = suite; - }, - onError: (error: reporterTypes.TestError) => { - errors.push(error); - }, - }, new this._vscode.CancellationTokenSource().token); - return { rootSuite: rootSuite!, errors }; - } - - private _useTestServer(config: TestConfig) { - return this._options.settingsModel.useTestServer.get(); - } - - private async _test(config: TestConfig, locations: string[], mode: 'list' | 'test', options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise { - if (this._useTestServer(config)) { - if (mode === 'test') - await this._testWithServer(config, locations, options, reporter, token); - else - await this._listWithServer(config, locations, reporter, token); - } else { - await this._testWithCLI(config, locations, mode, options, reporter, token); - } - } - - private async _testWithCLI(config: TestConfig, locations: string[], mode: 'list' | 'test', options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise { - // 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, config.workspaceFolder); - if (token?.isCancellationRequested) - return; - const configFolder = path.dirname(config.configFile); - const configFile = path.basename(config.configFile); - const escapedLocations = locations.map(escapeRegex).sort(); - const args = []; - if (mode === 'list') - args.push('--list', '--reporter=null'); - - if (options.projects) - options.projects.forEach(p => args.push(`--project=${p}`)); - if (options.grep) - args.push(`--grep=${escapeRegex(options.grep)}`); - - { - // For tests. - const relativeLocations = locations.map(f => path.relative(configFolder, f)).map(escapeRegex).sort(); - this._log(`${escapeRegex(path.relative(config.workspaceFolder, configFolder))}> playwright test -c ${configFile}${args.length ? ' ' + args.join(' ') : ''}${relativeLocations.length ? ' ' + relativeLocations.join(' ') : ''}`); - } - const allArgs = [config.cli, 'test', - '-c', configFile, - ...args, - ...escapedLocations, - '--repeat-each', '1', - '--retries', '0', - ]; - - if (options.headed) - allArgs.push('--headed'); - if (options.workers) - allArgs.push('--workers', String(options.workers)); - if (options.trace) - allArgs.push('--trace', options.trace); - - const childProcess = spawn(node, allArgs, { - cwd: configFolder, - stdio: ['pipe', 'pipe', 'pipe', 'pipe', 'pipe'], - env: { - ...process.env, - CI: this._options.isUnderTest ? undefined : process.env.CI, - // Don't debug tests when running them. - NODE_OPTIONS: undefined, - ...this._options.envProvider(), - PW_TEST_REUSE_CONTEXT: options.reuseContext ? '1' : undefined, - PW_TEST_CONNECT_WS_ENDPOINT: options.connectWsEndpoint, - ...(await reporterServer.env()), - // Reset VSCode's options that affect nested Electron. - ELECTRON_RUN_AS_NODE: undefined, - FORCE_COLOR: '1', - PW_TEST_HTML_REPORT_OPEN: 'never', - PW_TEST_NO_REMOVE_OUTPUT_DIRS: '1', - } - }); - - const stdio = childProcess.stdio; - stdio[1].on('data', data => reporter.onStdOut?.(data)); - stdio[2].on('data', data => reporter.onStdErr?.(data)); - await reporterServer.wireTestListener(mode, reporter, token); - } - - private async _listWithServer(config: TestConfig, locations: string[], reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise { - const testServer = await this._options.testServerController.testServerFor(config); - if (token?.isCancellationRequested) - return; - if (!testServer) - return; - const { report } = await testServer.listTests({ locations }); - const teleReceiver = new TeleReporterReceiver(reporter, { - mergeProjects: true, - mergeTestCases: true, - resolvePath: (rootDir: string, relativePath: string) => this._vscode.Uri.file(path.join(rootDir, relativePath)).fsPath, - }); - for (const message of report) - teleReceiver.dispatch(message); - } - - private async _testWithServer(config: TestConfig, locations: string[], options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise { - const testServer = await this._options.testServerController.testServerFor(config); - if (token?.isCancellationRequested) - return; - if (!testServer) - return; - testServer.runTests({ locations, ...options }); - token.onCancellationRequested(() => { - testServer.stopTestsNoReply({}); - }); - testServer.onStdio(params => { - if (params.type === 'stdout') - reporter.onStdOut?.(unwrapString(params)); - if (params.type === 'stderr') - reporter.onStdErr?.(unwrapString(params)); - }); - await this._options.testServerController.wireTestListener(testServer, reporter, token); - } - - async findRelatedTestFiles(config: TestConfig, files: string[]): Promise { - if (this._useTestServer(config)) - return await this._findRelatedTestFilesServer(config, files); - else - return await this._findRelatedTestFilesCLI(config, files); - } - - async _findRelatedTestFilesCLI(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, - }; - } - } - - private async _runNode(args: string[], cwd: string) { - return await runNode(this._vscode, args, cwd, this._options.envProvider()); - } - - async _findRelatedTestFilesServer(config: TestConfig, files: string[]): Promise { - const testServer = await this._options.testServerController.testServerFor(config); - if (!testServer) - return { testFiles: files, errors: [{ message: 'Internal error: unable to connect to the test server' }] }; - return await testServer.findRelatedTestFiles({ files }); - } - - async debugTests(vscode: vscodeTypes.VSCode, config: TestConfig, projectNames: string[], testDirs: string[], settingsEnv: NodeJS.ProcessEnv, locations: string[] | null, reporter: reporterTypes.ReporterV2, parametrizedTestTitle: string | undefined, token: vscodeTypes.CancellationToken) { - const configFolder = path.dirname(config.configFile); - const configFile = path.basename(config.configFile); - locations = locations || []; - const escapedLocations = locations.map(escapeRegex); - const args = ['test', - '-c', configFile, - ...escapedLocations, - '--headed', - ...projectNames.filter(Boolean).map(p => `--project=${p}`), - '--repeat-each', '1', - '--retries', '0', - '--timeout', '0', - '--workers', '1' - ]; - 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(config.workspaceFolder, configFolder))}> debug -c ${configFile}${relativeLocations.length ? ' ' + relativeLocations.join(' ') : ''}`); - } - - const reporterServer = new ReporterServer(this._vscode); - const testOptions = await this._options.runHooks.onWillRunTests(config, true); - try { - await vscode.debug.startDebugging(undefined, { - type: 'pwa-node', - name: debugSessionName, - request: 'launch', - cwd: configFolder, - env: { - ...process.env, - CI: this._options.isUnderTest ? undefined : process.env.CI, - ...settingsEnv, - PW_TEST_CONNECT_WS_ENDPOINT: testOptions.connectWsEndpoint, - ...(await reporterServer.env()), - // Reset VSCode's options that affect nested Electron. - ELECTRON_RUN_AS_NODE: undefined, - FORCE_COLOR: '1', - PW_TEST_SOURCE_TRANSFORM: require.resolve('./debugTransform'), - PW_TEST_SOURCE_TRANSFORM_SCOPE: testDirs.join(pathSeparator), - PW_TEST_HTML_REPORT_OPEN: 'never', - PWDEBUG: 'console', - }, - program: config.cli, - args, - }); - await reporterServer.wireTestListener('test', reporter, token); - } finally { - await this._options.runHooks.onDidRunTests(true); - } - } - - private _log(line: string) { - this._options.playwrightTestLog.push(line); - } -} - -function escapeRegex(text: string) { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function unwrapString(params: { text?: string, buffer?: string }): string | Buffer { - return params.buffer ? Buffer.from(params.buffer, 'base64') : params.text || ''; -} - -async function runNode(vscode: vscodeTypes.VSCode, args: string[], cwd: string, env: NodeJS.ProcessEnv): Promise { - return await spawnAsync(await findNode(vscode, cwd), args, cwd, env); -} - -export async function getPlaywrightInfo(vscode: vscodeTypes.VSCode, workspaceFolder: string, configFilePath: string, env: NodeJS.ProcessEnv): Promise<{ version: number, cli: string }> { - const pwtInfo = await runNode(vscode, [ - require.resolve('./playwrightFinder'), - ], path.dirname(configFilePath), env); - const { version, cli, error } = JSON.parse(pwtInfo) as { version: number, cli: string, error?: string }; - if (error) - throw new Error(error); - let cliOverride = cli; - if (cli.includes('/playwright/packages/playwright-test/') && configFilePath.includes('playwright-test')) - cliOverride = path.join(workspaceFolder, 'tests/playwright-test/stable-test-runner/node_modules/@playwright/test/cli.js'); - return { cli: cliOverride, version }; -} diff --git a/src/playwrightTestCLI.ts b/src/playwrightTestCLI.ts new file mode 100644 index 000000000..4d51a4811 --- /dev/null +++ b/src/playwrightTestCLI.ts @@ -0,0 +1,152 @@ +/** + * 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 { spawn } from 'child_process'; +import path from 'path'; +import { ConfigFindRelatedTestFilesReport, ConfigListFilesReport } from './listTests'; +import { ReporterServer } from './reporterServer'; +import { escapeRegex, findNode, runNode } from './utils'; +import * as vscodeTypes from './vscodeTypes'; +import * as reporterTypes from './upstream/reporter'; +import type { PlaywrightTestOptions, PlaywrightTestRunOptions, TestConfig } from './playwrightTestTypes'; + +export class PlaywrightTestCLI { + private _vscode: vscodeTypes.VSCode; + private _options: PlaywrightTestOptions; + private _config: TestConfig; + + constructor(vscode: vscodeTypes.VSCode, config: TestConfig, options: PlaywrightTestOptions) { + this._vscode = vscode; + this._config = config; + this._options = options; + } + + reset() { + } + + async listFiles(): Promise { + const configFolder = path.dirname(this._config.configFile); + const configFile = path.basename(this._config.configFile); + const allArgs = [this._config.cli, 'list-files', '-c', configFile]; + { + // For tests. + this._log(`${escapeRegex(path.relative(this._config.workspaceFolder, configFolder))}> playwright list-files -c ${configFile}`); + } + const output = await this._runNode(allArgs, configFolder); + const result = JSON.parse(output) as Partial; + return { + // list-files does not return `projects: []` if there is an error. + projects: [], + ...result, + }; + } + + async test(locations: string[], mode: 'list' | 'test', options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise { + // 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); + if (token?.isCancellationRequested) + return; + const configFolder = path.dirname(this._config.configFile); + const configFile = path.basename(this._config.configFile); + const escapedLocations = locations.map(escapeRegex).sort(); + const args = []; + if (mode === 'list') + args.push('--list', '--reporter=null'); + + if (options.projects) + options.projects.forEach(p => args.push(`--project=${p}`)); + if (options.grep) + args.push(`--grep=${escapeRegex(options.grep)}`); + + { + // For tests. + const relativeLocations = locations.map(f => path.relative(configFolder, f)).map(escapeRegex).sort(); + this._log(`${escapeRegex(path.relative(this._config.workspaceFolder, configFolder))}> playwright test -c ${configFile}${args.length ? ' ' + args.join(' ') : ''}${relativeLocations.length ? ' ' + relativeLocations.join(' ') : ''}`); + } + const allArgs = [this._config.cli, 'test', + '-c', configFile, + ...args, + ...escapedLocations, + '--repeat-each', '1', + '--retries', '0', + ]; + + if (options.headed) + allArgs.push('--headed'); + if (options.workers) + allArgs.push('--workers', String(options.workers)); + if (options.trace) + allArgs.push('--trace', options.trace); + + const childProcess = spawn(node, allArgs, { + cwd: configFolder, + stdio: ['pipe', 'pipe', 'pipe', 'pipe', 'pipe'], + env: { + ...process.env, + CI: this._options.isUnderTest ? undefined : process.env.CI, + // Don't debug tests when running them. + NODE_OPTIONS: undefined, + ...this._options.envProvider(), + PW_TEST_REUSE_CONTEXT: options.reuseContext ? '1' : undefined, + PW_TEST_CONNECT_WS_ENDPOINT: options.connectWsEndpoint, + ...(await reporterServer.env()), + // Reset VSCode's options that affect nested Electron. + ELECTRON_RUN_AS_NODE: undefined, + FORCE_COLOR: '1', + PW_TEST_HTML_REPORT_OPEN: 'never', + PW_TEST_NO_REMOVE_OUTPUT_DIRS: '1', + } + }); + + const stdio = childProcess.stdio; + stdio[1].on('data', data => reporter.onStdOut?.(data)); + stdio[2].on('data', data => reporter.onStdErr?.(data)); + await reporterServer.wireTestListener(mode, reporter, token); + } + + async findRelatedTestFiles(files: string[]): Promise { + 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]; + { + // For tests. + this._log(`${escapeRegex(path.relative(this._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, + }; + } + } + + private async _runNode(args: string[], cwd: string) { + return await runNode(this._vscode, args, cwd, this._options.envProvider()); + } + + private _log(line: string) { + this._options.playwrightTestLog.push(line); + } +} diff --git a/src/playwrightTestServer.ts b/src/playwrightTestServer.ts new file mode 100644 index 000000000..35cf214f4 --- /dev/null +++ b/src/playwrightTestServer.ts @@ -0,0 +1,193 @@ +/** + * 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 path from 'path'; +import { ConfigFindRelatedTestFilesReport, ConfigListFilesReport } from './listTests'; +import * as vscodeTypes from './vscodeTypes'; +import * as reporterTypes from './upstream/reporter'; +import { TeleReporterReceiver } from './upstream/teleReceiver'; +import { TestServerConnection } from './upstream/testServerConnection'; +import { startBackend } from './backend'; +import type { PlaywrightTestOptions, PlaywrightTestRunOptions, TestConfig } from './playwrightTestTypes'; + +export class PlaywrightTestServer { + private _vscode: vscodeTypes.VSCode; + private _options: PlaywrightTestOptions; + private _config: TestConfig; + private _testServerPromise: Promise | undefined; + + constructor(vscode: vscodeTypes.VSCode, config: TestConfig, options: PlaywrightTestOptions) { + this._vscode = vscode; + this._config = config; + this._options = options; + } + + reset() { + this._disposeTestServer(); + } + + async listFiles(): Promise { + const testServer = await this._testServer(); + if (!testServer) + throw new Error('Internal error: unable to connect to the test server'); + + const result: ConfigListFilesReport = { + projects: [], + }; + + // TODO: remove ConfigListFilesReport and report suite directly once CLI is deprecated. + const { report } = await testServer.listFiles({}); + const teleReceiver = new TeleReporterReceiver({ + onBegin(rootSuite) { + for (const projectSuite of rootSuite.suites) { + const project = projectSuite.project()!; + const files: string[] = []; + result.projects.push({ + name: project.name, + testDir: project.testDir, + use: project.use || {}, + files, + }); + for (const fileSuite of projectSuite.suites) + files.push(fileSuite.location!.file); + } + }, + onError(error) { + result.error = error; + }, + }, { + mergeProjects: true, + mergeTestCases: true, + resolvePath: (rootDir: string, relativePath: string) => this._vscode.Uri.file(path.join(rootDir, relativePath)).fsPath, + }); + for (const message of report) + teleReceiver.dispatch(message); + return result; + } + + async test(locations: string[], mode: 'list' | 'test', options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise { + if (mode === 'test') + await this._test(locations, options, reporter, token); + else + await this._list(locations, reporter, token); + } + + private async _list(locations: string[], reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise { + const testServer = await this._testServer(); + if (token?.isCancellationRequested) + return; + if (!testServer) + return; + const { report } = await testServer.listTests({ locations }); + const teleReceiver = new TeleReporterReceiver(reporter, { + mergeProjects: true, + mergeTestCases: true, + resolvePath: (rootDir: string, relativePath: string) => this._vscode.Uri.file(path.join(rootDir, relativePath)).fsPath, + }); + for (const message of report) + teleReceiver.dispatch(message); + } + + private async _test(locations: string[], options: PlaywrightTestRunOptions, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken): Promise { + const testServer = await this._testServer(); + if (token?.isCancellationRequested) + return; + if (!testServer) + return; + testServer.runTests({ locations, ...options }); + token.onCancellationRequested(() => { + testServer.stopTestsNoReply({}); + }); + testServer.onStdio(params => { + if (params.type === 'stdout') + reporter.onStdOut?.(unwrapString(params)); + if (params.type === 'stderr') + reporter.onStdErr?.(unwrapString(params)); + }); + await this._wireTestServer(testServer, reporter, token); + } + + async findRelatedTestFiles(files: string[]): Promise { + const testServer = await this._testServer(); + if (!testServer) + return { testFiles: files, errors: [{ message: 'Internal error: unable to connect to the test server' }] }; + return await testServer.findRelatedTestFiles({ files }); + } + + private _testServer() { + if (this._testServerPromise) + return this._testServerPromise; + this._testServerPromise = this._createTestServer(); + return this._testServerPromise; + } + + private async _createTestServer(): Promise { + const args = [this._config.cli, 'test-server', '-c', this._config.configFile]; + const wsEndpoint = await startBackend(this._vscode, { + args, + cwd: this._config.workspaceFolder, + envProvider: () => { + return { + ...this._options.envProvider(), + FORCE_COLOR: '1', + }; + }, + dumpIO: false, + onClose: () => { + this._testServerPromise = undefined; + }, + onError: error => { + this._testServerPromise = undefined; + }, + }); + if (!wsEndpoint) + return null; + const testServer = new TestServerConnection(wsEndpoint); + await testServer.connect(); + await testServer.setSerializer({ serializer: require.resolve('./oopReporter') }); + return testServer; + } + + private async _wireTestServer(testServer: TestServerConnection, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken) { + const teleReceiver = new TeleReporterReceiver(reporter, { + mergeProjects: true, + mergeTestCases: true, + resolvePath: (rootDir: string, relativePath: string) => this._vscode.Uri.file(path.join(rootDir, relativePath)).fsPath, + }); + return new Promise(resolve => { + const disposable = testServer.onReport(message => { + if (token.isCancellationRequested && message.method !== 'onEnd') + return; + teleReceiver.dispatch(message); + if (message.method === 'onEnd') { + disposable.dispose(); + resolve(); + } + }); + }); + } + + private _disposeTestServer() { + const testServer = this._testServerPromise; + this._testServerPromise = undefined; + if (testServer) + testServer.then(server => server?.closeGracefully({})); + } +} + +function unwrapString(params: { text?: string, buffer?: string }): string | Buffer { + return params.buffer ? Buffer.from(params.buffer, 'base64') : params.text || ''; +} diff --git a/src/playwrightTestTypes.ts b/src/playwrightTestTypes.ts new file mode 100644 index 000000000..e8df13378 --- /dev/null +++ b/src/playwrightTestTypes.ts @@ -0,0 +1,42 @@ +/** + * 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 { SettingsModel } from './settingsModel'; +import { TestServerInterface } from './upstream/testServerInterface'; + +export type TestConfig = { + workspaceFolder: string; + configFile: string; + cli: string; + version: number; + testIdAttributeName?: string; +}; + +type AllRunOptions = Parameters[0]; +export type PlaywrightTestRunOptions = Pick; + +export interface RunHooks { + onWillRunTests(config: TestConfig, debug: boolean): Promise<{ connectWsEndpoint?: string }>; + onDidRunTests(debug: boolean): Promise; +} + +export type PlaywrightTestOptions = { + settingsModel: SettingsModel; + runHooks: RunHooks; + isUnderTest: boolean; + playwrightTestLog: string[]; + envProvider: () => NodeJS.ProcessEnv; +}; diff --git a/src/reusedBrowser.ts b/src/reusedBrowser.ts index a32f8ad1f..a4ec653bc 100644 --- a/src/reusedBrowser.ts +++ b/src/reusedBrowser.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TestConfig } from './playwrightTest'; +import type { TestConfig } from './playwrightTestTypes'; import type { TestModel, TestModelCollection } from './testModel'; import { createGuid } from './utils'; import * as vscodeTypes from './vscodeTypes'; diff --git a/src/testModel.ts b/src/testModel.ts index 5b9be08a0..08d76855a 100644 --- a/src/testModel.ts +++ b/src/testModel.ts @@ -14,18 +14,22 @@ * limitations under the License. */ -import { PlaywrightTest, RunHooks, TestConfig } from './playwrightTest'; import { WorkspaceChange } from './workspaceObserver'; import * as vscodeTypes from './vscodeTypes'; -import { resolveSourceMap } from './utils'; -import { ProjectConfigWithFiles } from './listTests'; +import { escapeRegex, pathSeparator, resolveSourceMap } from './utils'; +import { ConfigListFilesReport, ProjectConfigWithFiles } from './listTests'; import * as reporterTypes from './upstream/reporter'; import { TeleSuite } from './upstream/teleReceiver'; import type { SettingsModel, WorkspaceSettings } from './settingsModel'; import path from 'path'; import { DisposableBase } from './disposableBase'; -import type { TestServerController } from './testServerController'; import { MultiMap } from './multimap'; +import { TestServerInterface } from './upstream/testServerInterface'; +import { ReporterServer } from './reporterServer'; +import { debugSessionName } from './debugSessionName'; +import { PlaywrightTestServer } from './playwrightTestServer'; +import type { RunHooks, TestConfig } from './playwrightTestTypes'; +import { PlaywrightTestCLI } from './playwrightTestCLI'; export type TestEntry = reporterTypes.TestCase | reporterTypes.Suite; @@ -41,29 +45,33 @@ export type TestModelOptions = { settingsModel: SettingsModel; runHooks: RunHooks; isUnderTest: boolean; - testServerController: TestServerController; playwrightTestLog: string[]; envProvider: () => NodeJS.ProcessEnv; }; +type AllRunOptions = Parameters[0]; +export type PlaywrightTestRunOptions = Pick; + export class TestModel { private _vscode: vscodeTypes.VSCode; readonly config: TestConfig; private _projects = new Map(); private _didUpdate: vscodeTypes.EventEmitter; readonly onUpdated: vscodeTypes.Event; - private _playwrightTest: PlaywrightTest; + private _playwrightTest: PlaywrightTestCLI | PlaywrightTestServer; private _fileToSources: Map = new Map(); private _sourceToFile: Map = new Map(); private _envProvider: () => NodeJS.ProcessEnv; isEnabled = false; readonly tag: vscodeTypes.TestTag; private _errorByFile = new MultiMap(); + private _options: TestModelOptions; constructor(vscode: vscodeTypes.VSCode, workspaceFolder: string, configFile: string, playwrightInfo: { cli: string, version: number }, options: TestModelOptions) { this._vscode = vscode; - this._playwrightTest = new PlaywrightTest(vscode, { configFile, ...options }); + this._options = options; this.config = { ...playwrightInfo, workspaceFolder, configFile }; + this._playwrightTest = options.settingsModel.useTestServer.get() ? new PlaywrightTestServer(vscode, this.config, options) : new PlaywrightTestCLI(vscode, this.config, options); this._didUpdate = new vscode.EventEmitter(); this.onUpdated = this._didUpdate.event; this._envProvider = options.envProvider; @@ -109,7 +117,23 @@ export class TestModel { } async _listFiles() { - const report = await this._playwrightTest.listFiles(this.config); + let report: ConfigListFilesReport; + try { + report = await this._playwrightTest.listFiles(); + for (const project of report.projects) + project.files = project.files.map(f => this._vscode.Uri.file(f).fsPath); + if (report.error?.location) + report.error.location.file = this._vscode.Uri.file(report.error.location.file).fsPath; + } catch (error: any) { + report = { + error: { + location: { file: this.config.configFile, line: 0, column: 0 }, + message: error.message, + }, + projects: [], + }; + } + if (report.error?.location) { this._errorByFile.set(report.error?.location.file, report.error); this._didUpdate.fire(); @@ -223,13 +247,21 @@ export class TestModel { } if (allFilesLoaded) return []; - const { rootSuite, errors } = await this._playwrightTest.listTests(this.config, files); - this._updateProjects(rootSuite.suites, files, errors); + await this.listTests(files); } async listTests(files: string[]) { - const { rootSuite, errors } = await this._playwrightTest.listTests(this.config, files); - this._updateProjects(rootSuite.suites, files, errors); + const errors: reporterTypes.TestError[] = []; + let rootSuite: reporterTypes.Suite | undefined; + await this._playwrightTest.test(files, 'list', {}, { + onBegin: (suite: reporterTypes.Suite) => { + rootSuite = suite; + }, + onError: (error: reporterTypes.TestError) => { + errors.push(error); + }, + }, new this._vscode.CancellationTokenSource().token); + this._updateProjects(rootSuite!.suites, files, errors); } private _updateProjects(newProjectSuites: reporterTypes.Suite[], requestedFiles: string[], errors: reporterTypes.TestError[]) { @@ -288,13 +320,100 @@ export class TestModel { async runTests(projects: TestProject[], locations: string[] | null, reporter: reporterTypes.ReporterV2, parametrizedTestTitle: string | undefined, token: vscodeTypes.CancellationToken) { locations = locations || []; - await this._playwrightTest.runTests(this.config, projects.map(p => p.name), locations, reporter, parametrizedTestTitle, token); + const locationArg = locations ? locations : []; + if (token?.isCancellationRequested) + return; + const externalOptions = await this._options.runHooks.onWillRunTests(this.config, false); + const showBrowser = this._options.settingsModel.showBrowser.get() && !!externalOptions.connectWsEndpoint; + + let trace: 'on' | 'off' | undefined; + if (this._options.settingsModel.showTrace.get()) + trace = 'on'; + // "Show browser" mode forces context reuse that survives over multiple test runs. + // Playwright Test sets up `tracesDir` inside the `test-results` folder, so it will be removed between runs. + // When context is reused, its ongoing tracing will fail with ENOENT because trace files + // were suddenly removed. So we disable tracing in this case. + if (this._options.settingsModel.showBrowser.get()) + trace = 'off'; + + const options: PlaywrightTestRunOptions = { + grep: parametrizedTestTitle, + projects: projects.length ? projects.map(p => p.name).filter(Boolean) : undefined, + headed: showBrowser && !this._options.isUnderTest, + workers: showBrowser ? 1 : undefined, + trace, + reuseContext: showBrowser, + connectWsEndpoint: showBrowser ? externalOptions.connectWsEndpoint : undefined, + }; + + try { + if (token?.isCancellationRequested) + return; + await this._playwrightTest.test(locationArg, 'test', options, reporter, token); + } finally { + await this._options.runHooks.onDidRunTests(false); + } } async debugTests(projects: TestProject[], locations: string[] | null, reporter: reporterTypes.ReporterV2, parametrizedTestTitle: string | undefined, token: vscodeTypes.CancellationToken) { locations = locations || []; const testDirs = projects.map(p => p.project.testDir); - await this._playwrightTest.debugTests(this._vscode, this.config, projects.map(p => p.name), testDirs, this._envProvider(), locations, reporter, parametrizedTestTitle, token); + const configFolder = path.dirname(this.config.configFile); + const configFile = path.basename(this.config.configFile); + locations = locations || []; + const escapedLocations = locations.map(escapeRegex); + const args = ['test', + '-c', configFile, + ...escapedLocations, + '--headed', + ...projects.map(p => p.name).filter(Boolean).map(p => `--project=${p}`), + '--repeat-each', '1', + '--retries', '0', + '--timeout', '0', + '--workers', '1' + ]; + 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(' ') : ''}`); + } + + const reporterServer = new ReporterServer(this._vscode); + const testOptions = await this._options.runHooks.onWillRunTests(this.config, true); + try { + await this._vscode.debug.startDebugging(undefined, { + type: 'pwa-node', + name: debugSessionName, + request: 'launch', + cwd: configFolder, + env: { + ...process.env, + CI: this._options.isUnderTest ? undefined : process.env.CI, + ...this._envProvider(), + PW_TEST_CONNECT_WS_ENDPOINT: testOptions.connectWsEndpoint, + ...(await reporterServer.env()), + // Reset VSCode's options that affect nested Electron. + ELECTRON_RUN_AS_NODE: undefined, + FORCE_COLOR: '1', + PW_TEST_SOURCE_TRANSFORM: require.resolve('./debugTransform'), + PW_TEST_SOURCE_TRANSFORM_SCOPE: testDirs.join(pathSeparator), + PW_TEST_HTML_REPORT_OPEN: 'never', + PWDEBUG: 'console', + }, + program: this.config.cli, + args, + }); + await reporterServer.wireTestListener('test', reporter, token); + } finally { + await this._options.runHooks.onDidRunTests(true); + } + } + + private _log(line: string) { + this._options.playwrightTestLog.push(line); } private _mapFilesToSources(testDirs: string[], files: Set): string[] { @@ -312,7 +431,7 @@ export class TestModel { } async findRelatedTestFiles(files: string[]) { - return await this._playwrightTest.findRelatedTestFiles(this.config, files); + return await this._playwrightTest.findRelatedTestFiles(files); } narrowDownFilesToEnabledProjects(fileNames: Set) { diff --git a/src/testServerController.ts b/src/testServerController.ts deleted file mode 100644 index 3b5fa9a38..000000000 --- a/src/testServerController.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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 { startBackend } from './backend'; -import type { TestConfig } from './playwrightTest'; -import { TeleReporterReceiver } from './upstream/teleReceiver'; -import { TestServerConnection } from './upstream/testServerConnection'; -import type * as vscodeTypes from './vscodeTypes'; -import * as reporterTypes from './upstream/reporter'; -import path from 'path'; - -export class TestServerController implements vscodeTypes.Disposable { - private _vscode: vscodeTypes.VSCode; - private _connectionPromises = new Map>(); - private _envProvider: () => NodeJS.ProcessEnv; - - constructor(vscode: vscodeTypes.VSCode, envProvider: () => NodeJS.ProcessEnv) { - this._vscode = vscode; - this._envProvider = envProvider; - } - - testServerFor(config: TestConfig): Promise { - let connectionPromise = this._connectionPromises.get(config.configFile); - if (connectionPromise) - return connectionPromise; - connectionPromise = this._createTestServer(config); - this._connectionPromises.set(config.configFile, connectionPromise); - return connectionPromise; - } - - disposeTestServerFor(configFile: string) { - const result = this._connectionPromises.get(configFile); - this._connectionPromises.delete(configFile); - if (result) - result.then(server => server?.closeGracefully({})); - } - - private async _createTestServer(config: TestConfig): Promise { - const args = [config.cli, 'test-server', '-c', config.configFile]; - const wsEndpoint = await startBackend(this._vscode, { - args, - cwd: config.workspaceFolder, - envProvider: () => { - return { - ...this._envProvider(), - FORCE_COLOR: '1', - }; - }, - dumpIO: false, - onClose: () => { - this._connectionPromises.delete(config.configFile); - }, - onError: error => { - this._connectionPromises.delete(config.configFile); - }, - }); - if (!wsEndpoint) - return null; - const testServer = new TestServerConnection(wsEndpoint); - await testServer.connect(); - await testServer.setSerializer({ serializer: require.resolve('./oopReporter') }); - return testServer; - } - - async wireTestListener(testServerConnection: TestServerConnection, reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken) { - const teleReceiver = new TeleReporterReceiver(reporter, { - mergeProjects: true, - mergeTestCases: true, - resolvePath: (rootDir: string, relativePath: string) => this._vscode.Uri.file(path.join(rootDir, relativePath)).fsPath, - }); - return new Promise(resolve => { - const disposable = testServerConnection.onReport(message => { - if (token.isCancellationRequested && message.method !== 'onEnd') - return; - teleReceiver.dispatch(message); - if (message.method === 'onEnd') { - disposable.dispose(); - resolve(); - } - }); - }); - } - - dispose() { - for (const instancePromise of this._connectionPromises.values()) - instancePromise.then(server => server?.closeGracefully({})); - } -} diff --git a/src/traceViewer.ts b/src/traceViewer.ts index 08b778de9..43c9375e3 100644 --- a/src/traceViewer.ts +++ b/src/traceViewer.ts @@ -15,7 +15,7 @@ */ import { ChildProcess, spawn } from 'child_process'; -import { TestConfig } from './playwrightTest'; +import type { TestConfig } from './playwrightTestTypes'; import { SettingsModel } from './settingsModel'; import { findNode } from './utils'; import * as vscodeTypes from './vscodeTypes'; diff --git a/src/utils.ts b/src/utils.ts index 6073ae8db..869ca18b1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -186,3 +186,26 @@ async function findNodeViaShell(vscode: vscodeTypes.VSCode, cwd: string): Promis }); }); } + +export function escapeRegex(text: string) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export const pathSeparator = process.platform === 'win32' ? ';' : ':'; + +export async function runNode(vscode: vscodeTypes.VSCode, args: string[], cwd: string, env: NodeJS.ProcessEnv): Promise { + return await spawnAsync(await findNode(vscode, cwd), args, cwd, env); +} + +export async function getPlaywrightInfo(vscode: vscodeTypes.VSCode, workspaceFolder: string, configFilePath: string, env: NodeJS.ProcessEnv): Promise<{ version: number, cli: string }> { + const pwtInfo = await runNode(vscode, [ + require.resolve('./playwrightFinder'), + ], path.dirname(configFilePath), env); + const { version, cli, error } = JSON.parse(pwtInfo) as { version: number, cli: string, error?: string }; + if (error) + throw new Error(error); + let cliOverride = cli; + if (cli.includes('/playwright/packages/playwright-test/') && configFilePath.includes('playwright-test')) + cliOverride = path.join(workspaceFolder, 'tests/playwright-test/stable-test-runner/node_modules/@playwright/test/cli.js'); + return { cli: cliOverride, version }; +} diff --git a/src/watchSupport.ts b/src/watchSupport.ts index c5a43f587..b51573d9e 100644 --- a/src/watchSupport.ts +++ b/src/watchSupport.ts @@ -16,7 +16,7 @@ import type { WorkspaceChange } from './workspaceObserver'; import type { TestModel } from './testModel'; -import type { TestConfig } from './playwrightTest'; +import type { TestConfig } from './playwrightTestTypes'; import * as vscodeTypes from './vscodeTypes'; import { MultiMap } from './multimap'; import { TestTree } from './testTree';