From cf6b17363f04b279dda57fd39a1c4b817acd310e Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 26 Nov 2025 13:51:12 +0100 Subject: [PATCH 1/3] feat: --pause --- packages/playwright-core/src/client/page.ts | 2 +- .../playwright-core/src/protocol/validator.ts | 1 + .../src/server/codegen/csharp.ts | 4 +- .../src/server/codegen/java.ts | 4 +- .../src/server/codegen/javascript.ts | 16 ++-- .../src/server/codegen/jsonl.ts | 2 +- .../src/server/codegen/language.ts | 14 ++-- .../src/server/codegen/python.ts | 6 +- .../src/server/codegen/types.ts | 7 +- .../src/server/recorder/recorderApp.ts | 1 + packages/playwright/src/DEPS.list | 1 + packages/playwright/src/common/config.ts | 2 +- packages/playwright/src/common/ipc.ts | 1 + packages/playwright/src/index.ts | 36 ++++++++- packages/playwright/src/program.ts | 5 +- packages/playwright/src/reporters/base.ts | 7 +- .../src/reporters/internalReporter.ts | 5 ++ packages/playwright/src/reporters/line.ts | 17 ++++- packages/playwright/src/reporters/list.ts | 16 ++++ .../playwright/src/reporters/multiplexer.ts | 5 ++ .../playwright/src/reporters/reporterV2.ts | 1 + packages/playwright/src/runner/dispatcher.ts | 8 ++ packages/playwright/src/runner/testRunner.ts | 4 +- .../playwright/src/transform/babelBundle.ts | 2 +- .../src/transform/babelHighlightUtils.ts | 76 +++++++++++++++++++ packages/playwright/src/worker/testInfo.ts | 6 +- packages/protocol/src/channels.d.ts | 2 + packages/protocol/src/protocol.yml | 5 ++ 28 files changed, 221 insertions(+), 35 deletions(-) create mode 100644 packages/playwright/src/transform/babelHighlightUtils.ts diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 62891e54bc083..cc3d90ffcc8ad 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -817,7 +817,7 @@ export class Page extends ChannelOwner implements api.Page this._browserContext.setDefaultNavigationTimeout(0); this._browserContext.setDefaultTimeout(0); this._instrumentation?.onWillPause({ keepTestTimeout: !!_options?.__testHookKeepTestTimeout }); - await this._closedOrCrashedScope.safeRace(this.context()._channel.pause()); + await this._closedOrCrashedScope.safeRace(this.context()._channel.pause(_options)); this._browserContext.setDefaultNavigationTimeout(defaultNavigationTimeout); this._browserContext.setDefaultTimeout(defaultTimeout); } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 18980d8e18b46..1d9afd4b432f7 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1056,6 +1056,7 @@ scheme.BrowserContextEnableRecorderParams = tObject({ language: tOptional(tString), mode: tOptional(tEnum(['inspecting', 'recording'])), recorderMode: tOptional(tEnum(['default', 'api'])), + snippet: tOptional(tEnum(['standalone', 'addition'])), pauseOnNextStatement: tOptional(tBoolean), testIdAttributeName: tOptional(tString), launchOptions: tOptional(tAny), diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index 32fb598cff64c..4022fd6ed02f9 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -221,9 +221,9 @@ export class CSharpLanguageGenerator implements LanguageGenerator { return formatter.format(); } - generateFooter(saveStorage: string | undefined): string { + generateFooter(options: LanguageGeneratorOptions): string { const offset = this._mode === 'library' ? '' : ' '; - let storageStateLine = saveStorage ? `\n${offset}await context.StorageStateAsync(new()\n${offset}{\n${offset} Path = ${quote(saveStorage)}\n${offset}});\n` : ''; + let storageStateLine = options.saveStorage ? `\n${offset}await context.StorageStateAsync(new()\n${offset}{\n${offset} Path = ${quote(options.saveStorage)}\n${offset}});\n` : ''; if (this._mode !== 'library') storageStateLine += ` }\n}\n`; return storageStateLine; diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 787bb86e50bad..8f4cb10734ec9 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -190,8 +190,8 @@ export class JavaLanguageGenerator implements LanguageGenerator { return formatter.format(); } - generateFooter(saveStorage: string | undefined): string { - const storageStateLine = saveStorage ? `\n context.storageState(new BrowserContext.StorageStateOptions().setPath(${quote(saveStorage)}));\n` : ''; + generateFooter(options: LanguageGeneratorOptions): string { + const storageStateLine = options.saveStorage ? `\n context.storageState(new BrowserContext.StorageStateOptions().setPath(${quote(options.saveStorage)}));\n` : ''; if (this._mode === 'junit') { return `${storageStateLine} } }`; diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index 3c4b266786053..3ea4c0098c525 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -35,13 +35,13 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { this._isTest = isTest; } - generateAction(actionInContext: actions.ActionInContext): string { + generateAction(actionInContext: actions.ActionInContext, options: LanguageGeneratorOptions): string { const action = actionInContext.action; if (this._isTest && (action.name === 'openPage' || action.name === 'closePage')) return ''; const pageAlias = actionInContext.frame.pageAlias; - const formatter = new JavaScriptFormatter(2); + const formatter = new JavaScriptFormatter(options.snippet === 'addition' ? 0 : 2); if (action.name === 'openPage') { formatter.add(`const ${pageAlias} = await context.newPage();`); @@ -131,16 +131,20 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { return asLocator('javascript', selector); } - generateHeader(options: LanguageGeneratorOptions): string { + generateHeader(options: LanguageGeneratorOptions): string | undefined { + if (options.snippet === 'addition') + return; if (this._isTest) return this.generateTestHeader(options); return this.generateStandaloneHeader(options); } - generateFooter(saveStorage: string | undefined): string { + generateFooter(options: LanguageGeneratorOptions): string | undefined { + if (options.snippet === 'addition') + return; if (this._isTest) - return this.generateTestFooter(saveStorage); - return this.generateStandaloneFooter(saveStorage); + return this.generateTestFooter(options.saveStorage); + return this.generateStandaloneFooter(options.saveStorage); } generateTestHeader(options: LanguageGeneratorOptions): string { diff --git a/packages/playwright-core/src/server/codegen/jsonl.ts b/packages/playwright-core/src/server/codegen/jsonl.ts index a15acfb8f8e76..84b5cf456d38d 100644 --- a/packages/playwright-core/src/server/codegen/jsonl.ts +++ b/packages/playwright-core/src/server/codegen/jsonl.ts @@ -40,7 +40,7 @@ export class JsonlLanguageGenerator implements LanguageGenerator { return JSON.stringify(options); } - generateFooter(saveStorage: string | undefined): string { + generateFooter(options: LanguageGeneratorOptions): string { return ''; } } diff --git a/packages/playwright-core/src/server/codegen/language.ts b/packages/playwright-core/src/server/codegen/language.ts index 1da8a915617ec..b7b9976a83dff 100644 --- a/packages/playwright-core/src/server/codegen/language.ts +++ b/packages/playwright-core/src/server/codegen/language.ts @@ -21,17 +21,17 @@ import type * as actions from '@recorder/actions'; export function generateCode(actions: actions.ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) { const header = languageGenerator.generateHeader(options); - const footer = languageGenerator.generateFooter(options.saveStorage); - const actionTexts = actions.map(a => generateActionText(languageGenerator, a, !!options.generateAutoExpect)).filter(Boolean) as string[]; - const text = [header, ...actionTexts, footer].join('\n'); + const footer = languageGenerator.generateFooter(options); + const actionTexts = actions.map(a => generateActionText(languageGenerator, a, options)).filter(Boolean) as string[]; + const text = [header, ...actionTexts, footer].filter(Boolean).join('\n'); return { header, footer, actionTexts, text }; } -function generateActionText(generator: LanguageGenerator, action: actions.ActionInContext, generateAutoExpect: boolean): string | undefined { - let text = generator.generateAction(action); +function generateActionText(generator: LanguageGenerator, action: actions.ActionInContext, options: LanguageGeneratorOptions): string | undefined { + let text = generator.generateAction(action, options); if (!text) return; - if (generateAutoExpect && action.action.preconditionSelector) { + if (options.generateAutoExpect && action.action.preconditionSelector) { const expectAction: actions.ActionInContext = { frame: action.frame, startTime: action.startTime, @@ -42,7 +42,7 @@ function generateActionText(generator: LanguageGenerator, action: actions.Action signals: [], }, }; - const expectText = generator.generateAction(expectAction); + const expectText = generator.generateAction(expectAction, options); if (expectText) text = expectText + '\n\n' + text; } diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 53204013848e2..8c8f8717d69f5 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -184,11 +184,11 @@ def run(playwright: Playwright) -> None { return formatter.format(); } - generateFooter(saveStorage: string | undefined): string { + generateFooter(options: LanguageGeneratorOptions): string { if (this._isPyTest) { return ''; } else if (this._isAsync) { - const storageStateLine = saveStorage ? `\n await context.storage_state(path=${quote(saveStorage)})` : ''; + const storageStateLine = options.saveStorage ? `\n await context.storage_state(path=${quote(options.saveStorage)})` : ''; return `\n # ---------------------${storageStateLine} await context.close() await browser.close() @@ -202,7 +202,7 @@ async def main() -> None: asyncio.run(main()) `; } else { - const storageStateLine = saveStorage ? `\n context.storage_state(path=${quote(saveStorage)})` : ''; + const storageStateLine = options.saveStorage ? `\n context.storage_state(path=${quote(options.saveStorage)})` : ''; return `\n # ---------------------${storageStateLine} context.close() browser.close() diff --git a/packages/playwright-core/src/server/codegen/types.ts b/packages/playwright-core/src/server/codegen/types.ts index c4d3c827bf379..6ec441c56170f 100644 --- a/packages/playwright-core/src/server/codegen/types.ts +++ b/packages/playwright-core/src/server/codegen/types.ts @@ -26,6 +26,7 @@ export type LanguageGeneratorOptions = { deviceName?: string; saveStorage?: string; generateAutoExpect?: boolean; + snippet?: 'standalone' | 'addition'; }; export interface LanguageGenerator { @@ -33,7 +34,7 @@ export interface LanguageGenerator { groupName: string; name: string; highlighter: Language; - generateHeader(options: LanguageGeneratorOptions): string; - generateAction(actionInContext: actions.ActionInContext): string; - generateFooter(saveStorage: string | undefined): string; + generateHeader(options: LanguageGeneratorOptions): string | undefined; + generateAction(actionInContext: actions.ActionInContext, options: LanguageGeneratorOptions): string; + generateFooter(options: LanguageGeneratorOptions): string | undefined; } diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index a216dc7006aaa..6bf8e08bff444 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -67,6 +67,7 @@ export class RecorderApp { contextOptions: { ...params.contextOptions }, deviceName: params.device, saveStorage: params.saveStorage, + snippet: params.snippet, }; this._throttledOutputFile = params.outputFile ? new ThrottledFile(params.outputFile) : null; diff --git a/packages/playwright/src/DEPS.list b/packages/playwright/src/DEPS.list index 680cf5e8870e8..71f14603bfa02 100644 --- a/packages/playwright/src/DEPS.list +++ b/packages/playwright/src/DEPS.list @@ -14,6 +14,7 @@ common/ ./mcp/sdk/ ./mcp/test/ ./transform/babelBundle.ts +./transform/babelHighlightUtils.ts ./agents/ [internalsForTest.ts] diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 463ac131337e9..b127d988067bc 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -102,7 +102,7 @@ export class FullConfigInternal { fullyParallel: takeFirst(configCLIOverrides.fullyParallel, userConfig.fullyParallel, false), globalSetup: this.globalSetups[0] ?? null, globalTeardown: this.globalTeardowns[0] ?? null, - globalTimeout: takeFirst(configCLIOverrides.debug ? 0 : undefined, configCLIOverrides.globalTimeout, userConfig.globalTimeout, 0), + globalTimeout: takeFirst((configCLIOverrides.debug || configCLIOverrides.pause) ? 0 : undefined, configCLIOverrides.globalTimeout, userConfig.globalTimeout, 0), grep: takeFirst(userConfig.grep, defaultGrep), grepInvert: takeFirst(userConfig.grepInvert, null), maxFailures: takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0), diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index 4d9ca7f846280..b4b250e592484 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -24,6 +24,7 @@ import type { SerializedCompilationCache } from '../transform/compilationCache' export type ConfigCLIOverrides = { debug?: boolean; + pause?: boolean; failOnFlakyTests?: boolean; forbidOnly?: boolean; fullyParallel?: boolean; diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 5265de9cd4ebc..fa0c9887b5060 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -24,8 +24,10 @@ import { currentTestInfo } from './common/globals'; import { rootTestType } from './common/testType'; import { createCustomMessageHandler } from './mcp/test/browserBackend'; import { performTask } from './agents/performTask'; +import { findTestEndPosition } from './transform/babelHighlightUtils'; +import { filteredStackTrace } from './util'; -import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test'; +import type { Fixtures, Location, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test'; import type { ContextReuseMode } from './common/config'; import type { TestInfoImpl, TestStepInternal } from './worker/testInfo'; import type { ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation'; @@ -231,12 +233,37 @@ const playwrightFixtures: Fixtures = ({ }); }, { box: true }], - _setupContextOptions: [async ({ playwright, _combinedContextOptions, actionTimeout, navigationTimeout, testIdAttribute }, use, testInfo) => { + _setupContextOptions: [async ({ playwright, _combinedContextOptions, actionTimeout, navigationTimeout, testIdAttribute, headless }, use, _testInfo) => { + const testInfo = _testInfo as TestInfoImpl; if (testIdAttribute) playwrightLibrary.selectors.setTestIdAttribute(testIdAttribute); testInfo.snapshotSuffix = process.platform; if (debugMode() === 'inspector') - (testInfo as TestInfoImpl)._setDebugMode(); + testInfo._setDebugMode(); + + if (testInfo._configInternal.configCLIOverrides.pause && !headless) { + testInfo._onTestPausedCallback = async () => { + const page = playwright._allPages()[0]; + if (!page) + return; + await page.context()._enableRecorder({ + snippet: 'addition', + testIdAttributeName: testIdAttribute, + }); + + let location: Location | undefined; + if (testInfo.error) { + if (testInfo.error.stack) + location = filteredStackTrace(testInfo.error.stack.split('\n'))[0]; + } else { + const source = await fs.promises.readFile(testInfo.file, 'utf-8'); + location = findTestEndPosition(source, testInfo); + } + location ??= testInfo; + await page.pause({ location: location } as any); + }; + } + playwright._defaultContextOptions = _combinedContextOptions; playwright._defaultContextTimeout = actionTimeout || 0; @@ -276,6 +303,9 @@ const playwrightFixtures: Fixtures = ({ return; } + if (data.apiName === 'page.pause' && data.frames.length === 0 && channel.params?.location) + data.frames.push(channel.params.location); + // In the general case, create a step for each api call and connect them through the stepId. const step = testInfo._addStep({ location: data.frames[0], diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 922ea766e094c..f5f4d0e1e4431 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -295,6 +295,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid globalTimeout: options.globalTimeout ? parseInt(options.globalTimeout, 10) : undefined, maxFailures: options.x ? 1 : (options.maxFailures ? parseInt(options.maxFailures, 10) : undefined), outputDir: options.output ? path.resolve(process.cwd(), options.output) : undefined, + pause: options.pause ? true : undefined, quiet: options.quiet ? options.quiet : undefined, repeatEach: options.repeatEach ? parseInt(options.repeatEach, 10) : undefined, retries: options.retries ? parseInt(options.retries, 10) : undefined, @@ -325,6 +326,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid overrides.use = { headless: false }; if (!options.ui && options.debug) { overrides.debug = true; + overrides.pause = true; process.env.PWDEBUG = '1'; } if (!options.ui && options.trace) { @@ -387,7 +389,7 @@ const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries const testOptions: [string, { description: string, choices?: string[], preset?: string }][] = [ /* deprecated */ ['--browser ', { description: `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")` }], ['-c, --config ', { description: `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"` }], - ['--debug', { description: `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options` }], + ['--debug', { description: `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1 --pause" options` }], ['--fail-on-flaky-tests', { description: `Fail if any test is flagged as flaky (default: false)` }], ['--forbid-only', { description: `Fail if test.only is called (default: false)` }], ['--fully-parallel', { description: `Run all tests in parallel (default: false)` }], @@ -403,6 +405,7 @@ const testOptions: [string, { description: string, choices?: string[], preset?: ['--output ', { description: `Folder for output artifacts (default: "test-results")` }], ['--only-changed [ref]', { description: `Only run test files that have been changed between 'HEAD' and 'ref'. Defaults to running all uncommitted changes. Only supports Git.` }], ['--pass-with-no-tests', { description: `Makes test run succeed even if no tests were found` }], + ['--pause', { description: `Pause test execution at end of test` }], ['--project ', { description: `Only run tests from the specified list of projects, supports '*' wildcard (default: run all projects)` }], ['--quiet', { description: `Suppress stdio` }], ['--repeat-each ', { description: `Run each test N times (default: 1)` }], diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index c73432201672c..1f3a85b6cda2a 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -153,6 +153,7 @@ export class TerminalReporter implements ReporterV2 { suite!: Suite; totalTestCount = 0; result!: FullResult; + paused = new Set(); private fileDurations = new Map }>(); private _options: TerminalReporterOptions; private _fatalErrors: TestError[] = []; @@ -191,6 +192,10 @@ export class TerminalReporter implements ReporterV2 { (result as any)[kOutputSymbol].push(output); } + onTestPaused(test: TestCase, result: TestResult) { + this.paused.add(result); + } + onTestEnd(test: TestCase, result: TestResult) { if (result.status !== 'skipped' && result.status !== test.expectedStatus) ++this._failureCount; @@ -312,7 +317,7 @@ export class TerminalReporter implements ReporterV2 { epilogue(full: boolean) { const summary = this.generateSummary(); const summaryMessage = this.generateSummaryMessage(summary); - if (full && summary.failuresToPrint.length && !this._options.omitFailures) + if (full && summary.failuresToPrint.length && !this._options.omitFailures && !this.paused.size) this._printFailures(summary.failuresToPrint); this._printSlowTests(); this._printSummary(summaryMessage); diff --git a/packages/playwright/src/reporters/internalReporter.ts b/packages/playwright/src/reporters/internalReporter.ts index 946c16932ba2f..9f8c252d20059 100644 --- a/packages/playwright/src/reporters/internalReporter.ts +++ b/packages/playwright/src/reporters/internalReporter.ts @@ -67,6 +67,11 @@ export class InternalReporter implements ReporterV2 { this._reporter.onStdErr?.(chunk, test, result); } + onTestPaused(test: TestCase, result: TestResult) { + this._addSnippetToTestErrors(test, result); + this._reporter.onTestPaused?.(test, result); + } + onTestEnd(test: TestCase, result: TestResult) { this._addSnippetToTestErrors(test, result); this._reporter.onTestEnd?.(test, result); diff --git a/packages/playwright/src/reporters/line.ts b/packages/playwright/src/reporters/line.ts index 3c34f22308d3e..f93b20cbc61a8 100644 --- a/packages/playwright/src/reporters/line.ts +++ b/packages/playwright/src/reporters/line.ts @@ -78,9 +78,24 @@ class LineReporter extends TerminalReporter { this._updateLine(test, result, step.parent); } + override onTestPaused(test: TestCase, result: TestResult) { + super.onTestPaused(test, result); + if (result.errors.length) { + if (!process.env.PW_TEST_DEBUG_REPORTERS) + this.screen.stdout.write(`\u001B[1A\u001B[2K`); + this.writeLine(this.formatFailure(test)); + this.writeLine(this.screen.colors.yellow(' Test paused on error. Press Ctrl+C to exit.')); + } else { + this.writeLine(); + this.writeLine(this.screen.colors.yellow(' Test paused at end. Press Ctrl+C to exit.')); + } + this.writeLine(); + this.writeLine(); + } + override onTestEnd(test: TestCase, result: TestResult) { super.onTestEnd(test, result); - if (!this.willRetry(test) && (test.outcome() === 'flaky' || test.outcome() === 'unexpected' || result.status === 'interrupted')) { + if (!this.willRetry(test) && (test.outcome() === 'flaky' || test.outcome() === 'unexpected' || result.status === 'interrupted') && !this.paused.has(result)) { if (!process.env.PW_TEST_DEBUG_REPORTERS) this.screen.stdout.write(`\u001B[1A\u001B[2K`); this.writeLine(this.formatFailure(test, ++this._failures)); diff --git a/packages/playwright/src/reporters/list.ts b/packages/playwright/src/reporters/list.ts index b2b399e6eb0d6..9db23cbb64b68 100644 --- a/packages/playwright/src/reporters/list.ts +++ b/packages/playwright/src/reporters/list.ts @@ -165,9 +165,25 @@ class ListReporter extends TerminalReporter { stream.write(chunk); } + override onTestPaused(test: TestCase, result: TestResult) { + super.onTestPaused(test, result); + if (result.errors.length) { + if (!process.env.PW_TEST_DEBUG_REPORTERS) + this.screen.stdout.write(`\u001B[1A\u001B[2K`); + this.writeLine(this.formatFailure(test)); + this.writeLine(this.screen.colors.yellow(' Test paused on error. Press Ctrl+C to exit.')); + } else { + this.writeLine(); + this.writeLine(this.screen.colors.yellow(' Test paused at end. Press Ctrl+C to exit.')); + } + } + override onTestEnd(test: TestCase, result: TestResult) { super.onTestEnd(test, result); + if (this.paused.has(result)) + return; + const title = this.formatTestTitle(test); let prefix = ''; let text = ''; diff --git a/packages/playwright/src/reporters/multiplexer.ts b/packages/playwright/src/reporters/multiplexer.ts index 2a11b9358319a..4d1f0abc4b432 100644 --- a/packages/playwright/src/reporters/multiplexer.ts +++ b/packages/playwright/src/reporters/multiplexer.ts @@ -54,6 +54,11 @@ export class Multiplexer implements ReporterV2 { wrap(() => reporter.onStdErr?.(chunk, test, result)); } + onTestPaused(test: TestCase, result: TestResult) { + for (const reporter of this._reporters) + wrap(() => reporter.onTestPaused?.(test, result)); + } + onTestEnd(test: TestCase, result: TestResult) { for (const reporter of this._reporters) wrap(() => reporter.onTestEnd?.(test, result)); diff --git a/packages/playwright/src/reporters/reporterV2.ts b/packages/playwright/src/reporters/reporterV2.ts index 280ef2bfd2f19..d41c618dc0b9b 100644 --- a/packages/playwright/src/reporters/reporterV2.ts +++ b/packages/playwright/src/reporters/reporterV2.ts @@ -22,6 +22,7 @@ export interface ReporterV2 { onTestBegin?(test: TestCase, result: TestResult): void; onStdOut?(chunk: string | Buffer, test?: TestCase, result?: TestResult): void; onStdErr?(chunk: string | Buffer, test?: TestCase, result?: TestResult): void; + onTestPaused?(test: TestCase, result: TestResult): void; onTestEnd?(test: TestCase, result: TestResult): void; onEnd?(result: FullResult): Promise<{ status?: FullResult['status'] } | undefined | void> | void; onExit?(): void | Promise; diff --git a/packages/playwright/src/runner/dispatcher.ts b/packages/playwright/src/runner/dispatcher.ts index 358f639ac01aa..3a29f28105276 100644 --- a/packages/playwright/src/runner/dispatcher.ts +++ b/packages/playwright/src/runner/dispatcher.ts @@ -585,6 +585,14 @@ class JobDispatcher { } private _onTestPaused(worker: WorkerHost, params: TestPausedPayload) { + const data = this._dataByTestId.get(params.testId); + if (!data) + return; + const { result, test } = data; + result.errors = params.errors; + result.error = result.errors[0]; + this._reporter.onTestPaused?.(test, result); + const sendMessage = async (message: { request: any }) => { try { if (this.jobResult.isDone()) diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index ec2397e31fee8..d432b7a19f421 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -470,7 +470,9 @@ export async function runAllTestsWithConfig(config: FullConfigInternal): Promise createLoadTask('in-process', { filterOnly: true, failOnLoadErrors: true }), ...createRunTestsTasks(config), ]; - const status = await runTasks(new TestRun(config, reporter), tasks, config.config.globalTimeout); + + const testRun = new TestRun(config, reporter, { pauseAtEnd: config.configCLIOverrides.pause, pauseOnError: config.configCLIOverrides.pause }); + const status = await runTasks(testRun, tasks, config.config.globalTimeout); // Calling process.exit() might truncate large stdout/stderr output. // See https://github.com/nodejs/node/issues/6456. diff --git a/packages/playwright/src/transform/babelBundle.ts b/packages/playwright/src/transform/babelBundle.ts index ce61eecc90a66..11ed0e4e22030 100644 --- a/packages/playwright/src/transform/babelBundle.ts +++ b/packages/playwright/src/transform/babelBundle.ts @@ -24,5 +24,5 @@ export type BabelTransformFunction = (code: string, filename: string, isModule: export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform; export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult; export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse; -export type { NodePath, PluginObj, types as T } from '../../bundles/babel/node_modules/@types/babel__core'; +export type { NodePath, PluginObj, types as T, ParseResult } from '../../bundles/babel/node_modules/@types/babel__core'; export type { BabelAPI } from '../../bundles/babel/node_modules/@types/babel__helper-plugin-utils'; diff --git a/packages/playwright/src/transform/babelHighlightUtils.ts b/packages/playwright/src/transform/babelHighlightUtils.ts new file mode 100644 index 0000000000000..dbe277e5c0bdc --- /dev/null +++ b/packages/playwright/src/transform/babelHighlightUtils.ts @@ -0,0 +1,76 @@ +/** + * 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 { traverse, babelParse, ParseResult, T, types as t } from './babelBundle'; +import type { Location } from '../../types/testReporter'; + +const astCache = new Map(); + +export function pruneAstCaches(fsPathsToRetain: string[]) { + const retain = new Set(fsPathsToRetain); + for (const key of astCache.keys()) { + if (!retain.has(key)) + astCache.delete(key); + } +} + +export type SourcePosition = { + line: number; // 1-based + column: number; // 1-based +}; + +function getAst(text: string, fsPath: string) { + const cached = astCache.get(fsPath); + let ast = cached?.ast; + if (!cached || cached.text !== text) { + try { + ast = babelParse(text, path.basename(fsPath), false); + astCache.set(fsPath, { text, ast }); + } catch (e) { + astCache.set(fsPath, { text, ast: undefined }); + } + } + return ast; +} + +function containsPosition(location: T.SourceLocation, position: SourcePosition): boolean { + if (position.line < location.start.line || position.line > location.end.line) + return false; + if (position.line === location.start.line && position.column < location.start.column) + return false; + if (position.line === location.end.line && position.column > location.end.column) + return false; + return true; +} + +export function findTestEndPosition(text: string, location: Location): Location | undefined { + const ast = getAst(text, location.file); + if (!ast) + return; + let result: Location | undefined; + traverse(ast, { + enter(path) { + if (t.isCallExpression(path.node) && path.node.loc && containsPosition(path.node.loc, { line: location.line, column: location.column })) { + const callNode = path.node; + const funcNode = callNode.arguments[callNode.arguments.length - 1]; + if (callNode.arguments.length >= 2 && t.isFunction(funcNode) && funcNode.body.loc) + result = { file: location.file, ...funcNode.body.loc.end }; + } + } + }); + return result; +} diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index c9fd959dd9fab..781c2d8edb825 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -87,6 +87,7 @@ export class TestInfoImpl implements TestInfo { private readonly _stepMap = new Map(); _onDidFinishTestFunctionCallback?: () => Promise; _onCustomMessageCallback?: (data: any) => Promise; + _onTestPausedCallback?: () => Promise; _hasNonRetriableError = false; _hasUnhandledError = false; _allowSkips = false; @@ -465,7 +466,10 @@ export class TestInfoImpl implements TestInfo { const shouldPause = (this._workerParams.pauseAtEnd && !this._isFailure()) || (this._workerParams.pauseOnError && this._isFailure()); if (shouldPause) { this._onTestPaused({ testId: this.testId, errors: this._isFailure() ? this.errors : [] }); - await this._interruptedPromise; + if (this._onTestPausedCallback) + await Promise.race([this._onTestPausedCallback(), this._interruptedPromise]); + else + await this._interruptedPromise; } await this._onDidFinishTestFunctionCallback?.(); } diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 8a2e3db8acef4..1561da08abed2 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1850,6 +1850,7 @@ export type BrowserContextEnableRecorderParams = { language?: string, mode?: 'inspecting' | 'recording', recorderMode?: 'default' | 'api', + snippet?: 'standalone' | 'addition', pauseOnNextStatement?: boolean, testIdAttributeName?: string, launchOptions?: any, @@ -1864,6 +1865,7 @@ export type BrowserContextEnableRecorderOptions = { language?: string, mode?: 'inspecting' | 'recording', recorderMode?: 'default' | 'api', + snippet?: 'standalone' | 'addition', pauseOnNextStatement?: boolean, testIdAttributeName?: string, launchOptions?: any, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 2f535bf6448e9..f457e13120fc1 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1327,6 +1327,11 @@ BrowserContext: literals: - default - api + snippet: + type: enum? + literals: + - standalone + - addition pauseOnNextStatement: boolean? testIdAttributeName: string? launchOptions: json? From c0ca38805ed0234b57ee204e4b270a7e3175de52 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 26 Nov 2025 14:42:56 +0100 Subject: [PATCH 2/3] fix mcp tests --- packages/playwright/src/mcp/test/testContext.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/mcp/test/testContext.ts b/packages/playwright/src/mcp/test/testContext.ts index dc17cd21333ac..a3267d1775b35 100644 --- a/packages/playwright/src/mcp/test/testContext.ts +++ b/packages/playwright/src/mcp/test/testContext.ts @@ -205,7 +205,7 @@ export class TestContext { claimStdio(); try { - const setupReporter = new ListReporter({ configDir, screen, includeTestId: true }); + const setupReporter = new McpReporter({ configDir, screen, includeTestId: true }); const { status } = await testRunner.runGlobalSetup([setupReporter]); if (status !== 'passed') return { output: testRunnerAndScreen.output.join('\n'), status }; @@ -227,7 +227,7 @@ export class TestContext { }; try { - const reporter = new ListReporter({ configDir, screen, includeTestId: true }); + const reporter = new McpReporter({ configDir, screen, includeTestId: true }); status = await Promise.race([ testRunner.runTests(reporter, params).then(result => result.status), testRunnerAndScreen.waitForTestPaused().then(() => 'paused' as const), @@ -271,6 +271,10 @@ export class TestContext { } } +class McpReporter extends ListReporter { + override onTestPaused() {} +} + export function createScreen() { const output: string[] = []; const stdout = new StringWriteStream(output, 'stdout'); From 21c3017957d793414bbc2a73ff67ae1e3056f1b2 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 26 Nov 2025 14:45:23 +0100 Subject: [PATCH 3/3] update pytest expectations --- tests/library/inspector/cli-codegen-pytest.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/library/inspector/cli-codegen-pytest.spec.ts b/tests/library/inspector/cli-codegen-pytest.spec.ts index 58676c5b42356..9892ec0ee0232 100644 --- a/tests/library/inspector/cli-codegen-pytest.spec.ts +++ b/tests/library/inspector/cli-codegen-pytest.spec.ts @@ -42,8 +42,7 @@ def browser_context_args(browser_context_args, playwright): def test_example(page: Page) -> None: - page.goto("${server.EMPTY_PAGE}") -`); + page.goto("${server.EMPTY_PAGE}")`); }); test('should save the codegen output to a file if specified', async ({ runCLI, server }, testInfo) => { @@ -53,8 +52,7 @@ from playwright.sync_api import Page, expect def test_example(page: Page) -> None: - page.goto("${server.EMPTY_PAGE}") -`); + page.goto("${server.EMPTY_PAGE}")`); }); test('should work with --save-har', async ({ runCLI }, testInfo) => {