diff --git a/src/extension.ts b/src/extension.ts index e082f0376..52f91bcb0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,7 +27,6 @@ import { TestTree } from './testTree'; import { NodeJSNotFoundError, ansiToHtml, getPlaywrightInfo } from './utils'; import * as vscodeTypes from './vscodeTypes'; import { WorkspaceChange, WorkspaceObserver } from './workspaceObserver'; -import { TraceViewer } from './traceViewer'; import { registerTerminalLinkProvider } from './terminalLinkProvider'; import { RunHooks, TestConfig } from './playwrightTestTypes'; @@ -67,7 +66,6 @@ export class Extension implements RunHooks { private _debugHighlight: DebugHighlight; private _isUnderTest: boolean; private _reusedBrowser: ReusedBrowser; - private _traceViewer: TraceViewer; private _settingsModel: SettingsModel; private _settingsView!: SettingsView; private _diagnostics: vscodeTypes.DiagnosticCollection; @@ -112,7 +110,6 @@ export class Extension implements RunHooks { onStdOut: this._debugHighlight.onStdOut.bind(this._debugHighlight), requestWatchRun: this._runWatchedTests.bind(this), }); - this._traceViewer = new TraceViewer(this._vscode, this._settingsModel, 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(() => {}); @@ -486,7 +483,7 @@ export class Extension implements RunHooks { // if trace viewer is currently displaying the trace file about to be replaced, it needs to be refreshed const prevTrace = (testItem as any)[traceUrlSymbol]; (testItem as any)[traceUrlSymbol] = trace; - if (enqueuedSingleTest || prevTrace === this._traceViewer.currentFile()) + if (enqueuedSingleTest || prevTrace === this._traceViewer()?.currentFile()) this._showTrace(testItem); if (result.status === test.expectedStatus) { @@ -538,7 +535,7 @@ export class Extension implements RunHooks { if (isDebug) { await model.debugTests(items, testListener, testRun.token); } else { - await this._traceViewer.willRunTests(model.config); + await this._traceViewer()?.willRunTests(); await model.runTests(items, testListener, testRun.token); } } @@ -755,20 +752,27 @@ export class Extension implements RunHooks { this._treeItemSelected(testItem); } + traceViewerInfoForTest() { + return this._traceViewer()?.infoForTest(); + } + private _showTrace(testItem: vscodeTypes.TestItem) { const traceUrl = (testItem as any)[traceUrlSymbol]; - const testModel = this._models.selectedModel(); - if (testModel) - this._traceViewer.open(traceUrl, testModel.config); + if (traceUrl) + this._traceViewer()?.open(traceUrl); } private _treeItemSelected(treeItem: vscodeTypes.TreeItem | null) { if (!treeItem) return; const traceUrl = (treeItem as any)[traceUrlSymbol] || ''; - const testModel = this._models.selectedModel(); - if (testModel) - this._traceViewer.open(traceUrl, testModel.config); + if (!traceUrl && !this._traceViewer()?.isStarted()) + return; + this._traceViewer()?.open(traceUrl); + } + + private _traceViewer() { + return this._models.selectedModel()?.traceViewer(); } private _queueCommand(callback: () => Promise, defaultValue: T): Promise { diff --git a/src/testModel.ts b/src/testModel.ts index 57e3794b9..702c93849 100644 --- a/src/testModel.ts +++ b/src/testModel.ts @@ -30,6 +30,7 @@ import type { PlaywrightTestRunOptions, RunHooks, TestConfig } from './playwrigh import { PlaywrightTestCLI } from './playwrightTestCLI'; import { upstreamTreeItem } from './testTree'; import { collectTestIds } from './upstream/testTree'; +import { SpawnTraceViewer } from './traceViewer'; export type TestEntry = reporterTypes.TestCase | reporterTypes.Suite; @@ -79,6 +80,7 @@ export class TestModel extends DisposableBase { private _startedDevServer = false; private _useLegacyCLIDriver: boolean; private _collection: TestModelCollection; + private _spawnTraceViewer: SpawnTraceViewer; constructor(collection: TestModelCollection, workspaceFolder: string, configFile: string, playwrightInfo: { cli: string, version: number }) { super(); @@ -89,6 +91,24 @@ export class TestModel extends DisposableBase { this._useLegacyCLIDriver = playwrightInfo.version < 1.44; this._playwrightTest = this._useLegacyCLIDriver ? new PlaywrightTestCLI(this._vscode, this, collection.embedder) : new PlaywrightTestServer(this._vscode, this, collection.embedder); this.tag = new this._vscode.TestTag(this.config.configFile); + this._spawnTraceViewer = new SpawnTraceViewer(this._vscode, this._embedder.envProvider, this.config); + + this._disposables = [ + this._embedder.settingsModel.showTrace.onChange(() => this._closeTraceViewerIfNeeded()), + this._collection.onUpdated(() => this._closeTraceViewerIfNeeded()), + ]; + } + + traceViewer() { + if (!this._embedder.settingsModel.showTrace.get()) + return; + if (this._spawnTraceViewer.checkVersion()) + return this._spawnTraceViewer; + } + + _closeTraceViewerIfNeeded() { + if (this._collection.selectedModel() !== this || !this._embedder.settingsModel.showTrace.get()) + this._spawnTraceViewer.close(); } async _loadModelIfNeeded(configSettings: ConfigSettings | undefined) { @@ -127,6 +147,7 @@ export class TestModel extends DisposableBase { this._playwrightTest.reset(); this._watches.clear(); this._ranGlobalSetup = false; + this._spawnTraceViewer.close(); } projects(): TestProject[] { diff --git a/src/traceViewer.ts b/src/traceViewer.ts index 1e2c188c2..0eb350fe4 100644 --- a/src/traceViewer.ts +++ b/src/traceViewer.ts @@ -16,68 +16,54 @@ import { ChildProcess, spawn } from 'child_process'; import type { TestConfig } from './playwrightTestTypes'; -import { SettingsModel } from './settingsModel'; import { findNode } from './utils'; import * as vscodeTypes from './vscodeTypes'; -export class TraceViewer implements vscodeTypes.Disposable { +export type TraceViewer = SpawnTraceViewer; + +export class SpawnTraceViewer { private _vscode: vscodeTypes.VSCode; private _envProvider: () => NodeJS.ProcessEnv; - private _disposables: vscodeTypes.Disposable[] = []; private _traceViewerProcess: ChildProcess | undefined; - private _settingsModel: SettingsModel; private _currentFile?: string; + private _config: TestConfig; + private _serverUrlPrefixForTest?: string; - constructor(vscode: vscodeTypes.VSCode, settingsModel: SettingsModel, envProvider: () => NodeJS.ProcessEnv) { + constructor(vscode: vscodeTypes.VSCode, envProvider: () => NodeJS.ProcessEnv, config: TestConfig) { this._vscode = vscode; this._envProvider = envProvider; - this._settingsModel = settingsModel; + this._config = config; + } - this._disposables.push(settingsModel.showTrace.onChange(value => { - if (!value && this._traceViewerProcess) - this.close().catch(() => {}); - })); + isStarted() { + return !!this._traceViewerProcess; } currentFile() { return this._currentFile; } - async willRunTests(config: TestConfig) { - if (this._settingsModel.showTrace.get()) - await this._startIfNeeded(config); + async willRunTests() { + await this._startIfNeeded(); } - async open(file: string, config: TestConfig) { - if (!this._settingsModel.showTrace.get()) - return; - if (!this._checkVersion(config)) - return; - if (!file && !this._traceViewerProcess) - return; - await this._startIfNeeded(config); + async open(file: string) { + await this._startIfNeeded(); this._traceViewerProcess?.stdin?.write(file + '\n'); this._currentFile = file; } - dispose() { - this.close().catch(() => {}); - for (const d of this._disposables) - d.dispose(); - this._disposables = []; - } - - private async _startIfNeeded(config: TestConfig) { - const node = await findNode(this._vscode, config.workspaceFolder); + private async _startIfNeeded() { + const node = await findNode(this._vscode, this._config.workspaceFolder); if (this._traceViewerProcess) return; - const allArgs = [config.cli, 'show-trace', `--stdin`]; + const allArgs = [this._config.cli, 'show-trace', `--stdin`]; if (this._vscode.env.remoteName) { allArgs.push('--host', '0.0.0.0'); allArgs.push('--port', '0'); } const traceViewerProcess = spawn(node, allArgs, { - cwd: config.workspaceFolder, + cwd: this._config.workspaceFolder, stdio: 'pipe', detached: true, env: { @@ -95,26 +81,44 @@ export class TraceViewer implements vscodeTypes.Disposable { }); traceViewerProcess.on('error', error => { this._vscode.window.showErrorMessage(error.message); - this.close().catch(() => {}); + this.close(); }); + if (this._vscode.isUnderTest) { + traceViewerProcess.stdout?.on('data', data => { + const match = data.toString().match(/Listening on (.*)/); + if (match) + this._serverUrlPrefixForTest = match[1]; + }); + } } - private _checkVersion( - config: TestConfig, - message: string = this._vscode.l10n.t('this feature') - ): boolean { + checkVersion() { const version = 1.35; - if (config.version < 1.35) { + if (this._config.version < version) { + const message = this._vscode.l10n.t('this feature'); this._vscode.window.showWarningMessage( - this._vscode.l10n.t('Playwright v{0}+ is required for {1} to work, v{2} found', version, message, config.version) + this._vscode.l10n.t('Playwright v{0}+ is required for {1} to work, v{2} found', version, message, this._config.version) ); return false; } return true; } - async close() { + close() { this._traceViewerProcess?.stdin?.end(); this._traceViewerProcess = undefined; + this._currentFile = undefined; + this._serverUrlPrefixForTest = undefined; + } + + infoForTest() { + if (!this._serverUrlPrefixForTest) + return; + return { + type: 'spawn', + serverUrlPrefix: this._serverUrlPrefixForTest, + testConfigFile: this._config.configFile, + traceFile: this.currentFile(), + }; } } diff --git a/src/vscodeTypes.ts b/src/vscodeTypes.ts index 96be58b27..0fce597bf 100644 --- a/src/vscodeTypes.ts +++ b/src/vscodeTypes.ts @@ -57,4 +57,6 @@ export type { TerminalLink, } from 'vscode'; -export type VSCode = typeof import('vscode'); +export type VSCode = typeof import('vscode') & { + isUnderTest?: boolean; +}; diff --git a/tests/mock/vscode.ts b/tests/mock/vscode.ts index de29c62ad..4527eba1d 100644 --- a/tests/mock/vscode.ts +++ b/tests/mock/vscode.ts @@ -498,6 +498,7 @@ export class TestController { private _didCreateTestRun = new EventEmitter(); readonly onDidCreateTestRun = this._didCreateTestRun.event; + refreshHandler: (item: TestItem | null) => Promise; resolveHandler: (item: TestItem | null) => Promise; constructor(readonly vscode: VSCode, id: string, label: string) { diff --git a/tests/spawn-trace-viewer.spec.ts b/tests/spawn-trace-viewer.spec.ts new file mode 100644 index 000000000..e6b13ccaf --- /dev/null +++ b/tests/spawn-trace-viewer.spec.ts @@ -0,0 +1,187 @@ +/** + * 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 { enableConfigs, expect, selectConfig, selectTestItem, test, traceViewerInfo } from './utils'; + +test.beforeEach(({ showBrowser }) => { + test.skip(showBrowser); + // prevents spawn trace viewer process from opening in browser + process.env.PWTEST_UNDER_TEST = '1'; +}); + +test.use({ showTrace: true, envRemoteName: 'ssh-remote' }); + +test('@smoke should open trace viewer', async ({ activate }) => { + const { vscode, testController } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test.spec.ts': ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `, + }); + + await testController.run(); + await testController.expandTestItems(/test.spec/); + selectTestItem(testController.findTestItems(/pass/)[0]); + + await expect.poll(() => traceViewerInfo(vscode)).toMatchObject({ + type: 'spawn', + traceFile: expect.stringContaining('pass'), + }); +}); + +test('should change opened file in trace viewer', async ({ activate }) => { + const { vscode, testController } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test.spec.ts': ` + import { test } from '@playwright/test'; + test('one', async () => {}); + test('two', async () => {}); + `, + }); + + await testController.run(); + await testController.expandTestItems(/test.spec/); + + selectTestItem(testController.findTestItems(/one/)[0]); + + await expect.poll(() => traceViewerInfo(vscode)).toMatchObject({ + type: 'spawn', + traceFile: expect.stringContaining('one'), + }); + + selectTestItem(testController.findTestItems(/two/)[0]); + + await expect.poll(() => traceViewerInfo(vscode)).toMatchObject({ + type: 'spawn', + traceFile: expect.stringContaining('two'), + }); +}); + +test('should not open trace viewer if test did not run', async ({ activate }) => { + const { vscode, testController } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test.spec.ts': ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `, + }); + + await testController.expandTestItems(/test.spec/); + selectTestItem(testController.findTestItems(/pass/)[0]); + + await expect.poll(() => traceViewerInfo(vscode)).toBeUndefined(); +}); + +test('should refresh trace viewer while test is running', async ({ activate, createLatch }) => { + const latch = createLatch(); + + const { vscode, testController } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test.spec.ts': ` + import { test } from '@playwright/test'; + test('should pass', async () => ${latch.blockingCode}); + `, + }); + + await testController.expandTestItems(/test.spec/); + selectTestItem(testController.findTestItems(/pass/)[0]); + + const testRunPromise = testController.run(); + await expect.poll(() => traceViewerInfo(vscode)).toMatchObject({ + type: 'spawn', + traceFile: expect.stringMatching(/\.json$/), + }); + + latch.open(); + await testRunPromise; + + await expect.poll(() => traceViewerInfo(vscode)).toMatchObject({ + type: 'spawn', + traceFile: expect.stringMatching(/\.zip$/), + }); +}); + +test('should close trace viewer if test configs refreshed', async ({ activate }) => { + const { vscode, testController } = await activate({ + 'playwright.config.js': `module.exports = { testDir: 'tests' }`, + 'tests/test.spec.ts': ` + import { test } from '@playwright/test'; + test('should pass', async () => {}); + `, + }); + + await testController.run(); + await testController.expandTestItems(/test.spec/); + selectTestItem(testController.findTestItems(/pass/)[0]); + + await expect.poll(() => traceViewerInfo(vscode)).toMatchObject({ + type: 'spawn', + traceFile: expect.stringContaining('pass'), + }); + + await testController.refreshHandler(null); + + await expect.poll(() => traceViewerInfo(vscode)).toBeUndefined(); +}); + +test('should open new trace viewer when another test config is selected', async ({ activate }) => { + const { vscode, testController } = await activate({ + 'playwright1.config.js': `module.exports = { testDir: 'tests1' }`, + 'playwright2.config.js': `module.exports = { testDir: 'tests2' }`, + 'tests1/test.spec.ts': ` + import { test } from '@playwright/test'; + test('one', () => {}); + `, + 'tests2/test.spec.ts': ` + import { test } from '@playwright/test'; + test('one', () => {}); + `, + }); + + await enableConfigs(vscode, ['playwright1.config.js', 'playwright2.config.js']); + await selectConfig(vscode, 'playwright1.config.js'); + + await testController.expandTestItems(/test.spec/); + const testItems = testController.findTestItems(/one/); + await testController.run(testItems); + + selectTestItem(testItems[0]); + + await expect.poll(() => traceViewerInfo(vscode)).toMatchObject({ + type: 'spawn', + serverUrlPrefix: expect.stringContaining('http'), + testConfigFile: expect.stringContaining('playwright1.config.js'), + }); + const serverUrlPrefix1 = traceViewerInfo(vscode); + + // closes opened trace viewer + await selectConfig(vscode, 'playwright2.config.js'); + + await expect.poll(() => traceViewerInfo(vscode)).toBeUndefined(); + + // opens trace viewer from selected test config + selectTestItem(testItems[0]); + + await expect.poll(() => traceViewerInfo(vscode)).toMatchObject({ + type: 'spawn', + serverUrlPrefix: expect.stringContaining('http'), + testConfigFile: expect.stringContaining('playwright2.config.js'), + }); + const serverUrlPrefix2 = traceViewerInfo(vscode); + + expect(serverUrlPrefix2).not.toBe(serverUrlPrefix1); +}); diff --git a/tests/utils.ts b/tests/utils.ts index b2e845dca..e96c35e7d 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -18,6 +18,8 @@ import { expect as baseExpect, test as baseTest, Browser, chromium, Page } from import { Extension } from '../out/extension'; import { TestController, VSCode, WorkspaceFolder, TestRun, TestItem } from './mock/vscode'; +import crypto from 'crypto'; +import fs from 'fs'; import path from 'path'; type ActivateResult = { @@ -26,9 +28,18 @@ type ActivateResult = { workspaceFolder: WorkspaceFolder; }; +type Latch = { + blockingCode: string; + open: () => void; + close: () => void; +}; + type TestFixtures = { vscode: VSCode, activate: (files: { [key: string]: string }, options?: { rootDir?: string, workspaceFolders?: [string, any][], env?: Record }) => Promise; + showTrace: boolean; + envRemoteName?: string; + createLatch: () => Latch; }; export type WorkerOptions = { @@ -114,12 +125,14 @@ export const test = baseTest.extend({ overridePlaywrightVersion: [undefined, { option: true, scope: 'worker' }], showBrowser: [false, { option: true, scope: 'worker' }], vsCodeVersion: [1.86, { option: true, scope: 'worker' }], + showTrace: false, + envRemoteName: undefined, vscode: async ({ browser, vsCodeVersion }, use) => { await use(new VSCode(vsCodeVersion, path.resolve(__dirname, '..'), browser)); }, - activate: async ({ vscode, showBrowser, overridePlaywrightVersion }, use, testInfo) => { + activate: async ({ vscode, showBrowser, showTrace, envRemoteName, overridePlaywrightVersion }, use, testInfo) => { const instances: VSCode[] = []; await use(async (files: { [key: string]: string }, options?: { rootDir?: string, workspaceFolders?: [string, any][], env?: Record }) => { if (options?.workspaceFolders) { @@ -134,6 +147,10 @@ export const test = baseTest.extend({ configuration.update('env', options.env); if (showBrowser) configuration.update('reuseBrowser', true); + if (showTrace) + configuration.update('showTrace', true); + if (envRemoteName) + vscode.env.remoteName = envRemoteName; const extension = new Extension(vscode, vscode.context); if (overridePlaywrightVersion) @@ -152,6 +169,18 @@ export const test = baseTest.extend({ for (const vscode of instances) vscode.dispose(); }, + + // Copied from https://github.com/microsoft/playwright/blob/7e7319da7d84de6648900e27e6d844bec9071222/tests/playwright-test/ui-mode-fixtures.ts#L132 + createLatch: async ({}, use, testInfo) => { + await use(() => { + const latchFile = path.join(testInfo.project.outputDir, createGuid() + '.latch'); + return { + blockingCode: `await ((${waitForLatch})(${JSON.stringify(latchFile)}))`, + open: () => fs.writeFileSync(latchFile, 'ok'), + close: () => fs.unlinkSync(latchFile), + }; + }); + }, }); export async function connectToSharedBrowser(vscode: VSCode) { @@ -222,3 +251,17 @@ export async function singleWebViewByPanelType(vscode: VSCode, viewType: string) await expect.poll(() => vscode.webViewsByPanelType(viewType)).toHaveLength(1); return vscode.webViewsByPanelType(viewType)[0]; } + +export function traceViewerInfo(vscode: VSCode): { type: 'spawn' | 'embedded', serverUrlPrefix?: string, testConfigFile: string } | undefined { + return vscode.extensions[0].traceViewerInfoForTest(); +} + +async function waitForLatch(latchFile: string) { + const fs = require('fs'); + while (!fs.existsSync(latchFile)) + await new Promise(f => setTimeout(f, 250)); +} + +function createGuid(): string { + return crypto.randomBytes(16).toString('hex'); +}