diff --git a/src/extension.ts b/src/extension.ts index bfbedb3c6..ea68a5068 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -408,13 +408,7 @@ export class Extension implements RunHooks { const result = model.narrowDownLocations(items); if (!result.testIds && !result.locations) continue; - let globalSetupResult: reporterTypes.FullResult['status'] = 'passed'; - if (model.canRunGlobalHooks('setup')) { - const testListener = this._errorReportingListener(this._testRun, testItemForGlobalErrors); - globalSetupResult = await model.runGlobalHooks('setup', testListener); - } - if (globalSetupResult === 'passed') - await this._runTest(this._testRun, items, testItemForGlobalErrors, new Set(), model, mode === 'debug', enqueuedTests.length === 1); + await this._runTest(this._testRun, items, testItemForGlobalErrors, new Set(), model, mode === 'debug', enqueuedTests.length === 1); } } finally { this._activeSteps.clear(); diff --git a/src/playwrightTestServer.ts b/src/playwrightTestServer.ts index de974cb7d..b585e5d2c 100644 --- a/src/playwrightTestServer.ts +++ b/src/playwrightTestServer.ts @@ -104,7 +104,10 @@ export class PlaywrightTestServer { const testServer = await this._testServer(); if (!testServer) return 'failed'; + return await this._runGlobalHooksInServer(testServer, type, testListener); + } + private async _runGlobalHooksInServer(testServer: TestServerConnection, type: 'setup' | 'teardown', testListener: reporterTypes.ReporterV2): Promise<'passed' | 'failed' | 'interrupted' | 'timedout'> { const teleReceiver = new TeleReporterReceiver(testListener, { mergeProjects: true, mergeTestCases: true, @@ -119,8 +122,8 @@ export class PlaywrightTestServer { try { if (type === 'setup') { - const { report, status } = await testServer.runGlobalSetup({}); testListener.onStdOut?.('\x1b[2mRunning global setup if any\u2026\x1b[0m\n'); + const { report, status } = await testServer.runGlobalSetup({}); for (const message of report) teleReceiver.dispatch(message); return status; @@ -207,7 +210,7 @@ export class PlaywrightTestServer { const testDirs = this._model.enabledProjects().map(project => project.project.testDir); - let testServer: TestServerConnection | undefined; + let debugTestServer: TestServerConnection | undefined; let disposable: vscodeTypes.Disposable | undefined; try { await this._vscode.debug.startDebugging(undefined, { @@ -233,8 +236,8 @@ export class PlaywrightTestServer { if (token?.isCancellationRequested) return; const address = await addressPromise; - testServer = new TestServerConnection(address); - await testServer.initialize({ + debugTestServer = new TestServerConnection(address); + await debugTestServer.initialize({ serializer: require.resolve('./oopReporter'), closeOnDisconnect: true, }); @@ -245,6 +248,12 @@ export class PlaywrightTestServer { if (!locations && !testIds) return; + const result = await this._runGlobalHooksInServer(debugTestServer, 'setup', reporter); + if (result !== 'passed') + return; + if (token?.isCancellationRequested) + return; + // Locations are regular expressions. const locationPatterns = locations ? locations.map(escapeRegex) : undefined; const options: Parameters['0'] = { @@ -253,22 +262,24 @@ export class PlaywrightTestServer { testIds, ...runOptions, }; - testServer.runTests(options); + debugTestServer.runTests(options); token.onCancellationRequested(() => { - testServer!.stopTestsNoReply({}); + debugTestServer!.stopTestsNoReply({}); }); - disposable = testServer.onStdio(params => { + disposable = debugTestServer.onStdio(params => { if (params.type === 'stdout') reporter.onStdOut?.(unwrapString(params)); if (params.type === 'stderr') reporter.onStdErr?.(unwrapString(params)); }); - const testEndPromise = this._wireTestServer(testServer, reporter, token); + const testEndPromise = this._wireTestServer(debugTestServer, reporter, token); await testEndPromise; } finally { disposable?.dispose(); - testServer?.close(); + if (!token.isCancellationRequested && debugTestServer && !debugTestServer.isClosed()) + await this._runGlobalHooksInServer(debugTestServer, 'teardown', reporter); + debugTestServer?.close(); await this._options.runHooks.onDidRunTests(true); } } diff --git a/src/testModel.ts b/src/testModel.ts index 3cfac279a..3cd63ac3a 100644 --- a/src/testModel.ts +++ b/src/testModel.ts @@ -418,6 +418,8 @@ export class TestModel { } async runGlobalHooks(type: 'setup' | 'teardown', testListener: reporterTypes.ReporterV2): Promise { + if (!this.canRunGlobalHooks(type)) + return 'passed'; if (type === 'setup') { if (this._ranGlobalSetup) return 'passed'; @@ -465,6 +467,14 @@ export class TestModel { async runTests(items: vscodeTypes.TestItem[], reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken) { if (token?.isCancellationRequested) return; + + // Run global setup with the first test. + let globalSetupResult: reporterTypes.FullResult['status'] = 'passed'; + if (this.canRunGlobalHooks('setup')) + globalSetupResult = await this.runGlobalHooks('setup', reporter); + if (globalSetupResult !== 'passed') + return; + const externalOptions = await this._options.runHooks.onWillRunTests(this.config, false); const showBrowser = this._options.settingsModel.showBrowser.get() && !!externalOptions.connectWsEndpoint; @@ -503,6 +513,12 @@ export class TestModel { async debugTests(items: vscodeTypes.TestItem[], reporter: reporterTypes.ReporterV2, token: vscodeTypes.CancellationToken) { if (token?.isCancellationRequested) return; + + // Underlying debugTest implementation will run the global setup. + await this.runGlobalHooks('teardown', reporter); + if (token?.isCancellationRequested) + return; + const externalOptions = await this._options.runHooks.onWillRunTests(this.config, true); const options: PlaywrightTestRunOptions = { headed: !this._options.isUnderTest, diff --git a/src/upstream/testServerConnection.ts b/src/upstream/testServerConnection.ts index 2ab630527..518931398 100644 --- a/src/upstream/testServerConnection.ts +++ b/src/upstream/testServerConnection.ts @@ -40,6 +40,7 @@ export class TestServerConnection implements TestServerInterface, TestServerInte private _ws: WebSocket; private _callbacks = new Map void, reject: (arg: Error) => void }>(); private _connectedPromise: Promise; + private _isClosed = false; constructor(wsURL: string) { this.onClose = this._onCloseEmitter.event; @@ -72,11 +73,16 @@ export class TestServerConnection implements TestServerInterface, TestServerInte this._ws.addEventListener('error', r); }); this._ws.addEventListener('close', () => { + this._isClosed = true; this._onCloseEmitter.fire(); clearInterval(pingInterval); }); } + isClosed(): boolean { + return this._isClosed; + } + private async _sendMessage(method: string, params?: any): Promise { const logForTest = (globalThis as any).__logForTest; logForTest?.({ method, params }); diff --git a/tests/debug-tests.spec.ts b/tests/debug-tests.spec.ts index b81e51ffe..c8adbe17a 100644 --- a/tests/debug-tests.spec.ts +++ b/tests/debug-tests.spec.ts @@ -15,7 +15,7 @@ */ import { expect, test, escapedPathSep } from './utils'; -import { TestRun, DebugSession } from './mock/vscode'; +import { TestRun, DebugSession, stripAnsi } from './mock/vscode'; test('should debug all tests', async ({ activate }) => { const { vscode } = await activate({ @@ -56,6 +56,7 @@ test('should debug all tests', async ({ activate }) => { testIds: undefined }) }, + { method: 'runGlobalTeardown', params: {} }, ]); }); @@ -101,6 +102,7 @@ test('should debug one test', async ({ activate }) => { testIds: [expect.any(String)] }) }, + { method: 'runGlobalTeardown', params: {} }, ]); }); @@ -199,3 +201,52 @@ test('should pass all args as string[] when debugging', async ({ activate }) => expect(session.configuration.args.filter(arg => typeof arg !== 'string')).toEqual([]); await onDidTerminateDebugSession; }); + +test('should run global setup before debugging', async ({ activate }, testInfo) => { + const { vscode, testController } = await activate({ + 'playwright.config.js': `module.exports = { + testDir: 'tests', + globalSetup: 'globalSetup.ts', + globalTeardown: 'globalTeardown.ts', + }`, + 'globalSetup.ts': ` + async function globalSetup(config: FullConfig) { + console.log('RUN GLOBAL SETUP'); + process.env.MAGIC_NUMBER = '42'; + } + export default globalSetup; + `, + 'globalTeardown.ts': ` + async function globalTeardown(config: FullConfig) { + console.log('RUN GLOBAL TEARDOWN'); + delete process.env.MAGIC_NUMBER; + } + export default globalTeardown; + `, + 'tests/test.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('should pass', async () => { + console.log('MAGIC NUMBER: ' + process.env.MAGIC_NUMBER); + expect(process.env.MAGIC_NUMBER).toBe('42'); + }); + ` + }); + + const testRunPromise = new Promise(f => testController.onDidCreateTestRun(f)); + await testController.expandTestItems(/test.spec/); + const testItems = testController.findTestItems(/pass/); + const profile = testController.debugProfile(); + await profile.run(testItems); + const testRun = await testRunPromise; + expect(testRun.renderLog({ messages: true })).toBe(` + tests > test.spec.ts > should pass [2:0] + enqueued + enqueued + started + passed + `); + + await expect.poll(() => stripAnsi(vscode.debug.output)).toContain(`RUN GLOBAL SETUP`); + await expect.poll(() => stripAnsi(vscode.debug.output)).toContain(`MAGIC NUMBER: 42`); + await expect.poll(() => stripAnsi(vscode.debug.output)).toContain(`RUN GLOBAL TEARDOWN`); +}); diff --git a/tests/mock/vscode.ts b/tests/mock/vscode.ts index 0cadd7204..dcfee846a 100644 --- a/tests/mock/vscode.ts +++ b/tests/mock/vscode.ts @@ -1148,6 +1148,6 @@ function trimLog(log: string) { } const ansiRegex = new RegExp('[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', 'g'); -function stripAnsi(str: string): string { +export function stripAnsi(str: string): string { return str.replace(ansiRegex, ''); }