From 81881e2c1ba7c9cb96db3eab97c0a043b32e0725 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Wed, 10 Jun 2026 12:12:33 -0600 Subject: [PATCH 1/2] fix(firefox): treat `navigationCommitted` events without `navigationId` as same-document URL updates (#41224) Firefox juggler replays the current frame state by emitting `Page.navigationCommitted` with no `navigationId` after a process swap (e.g. while restoring a persistent profile) in Windows these replayed events can arrive after `Page.ready`, racing with a user `goto` and causing `Frame.gotoImpl` to throw `"interrupted by another navigation"` treat these as same-document URL updates so they don't interrupt any in-flight new-document navigation in `Frame.gotoImpl` fixes --- .../src/server/firefox/ffPage.ts | 9 ++++++++- tests/library/defaultbrowsercontext-2.spec.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 12f386f3560b9..f291c5f6fbd7f 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -239,11 +239,18 @@ export class FFPage implements PageDelegate { } _onNavigationCommitted(params: Protocol.Page.navigationCommittedPayload) { + if (!params.navigationId) { + // Firefox replays the current navigation without a navigationId during a process swap (e.g. when restoring a persistent profile). + // Treat these as same-document URL updates so they don't interrupt any in-flight new-document navigation in Frame.gotoImpl. + this._page.frameManager.frameCommittedSameDocumentNavigation(params.frameId, params.url); + return; + } + for (const [workerId, worker] of this._workers) { if (worker.frameId === params.frameId) this._onWorkerDestroyed({ workerId }); } - this._page.frameManager.frameCommittedNewDocumentNavigation(params.frameId, params.url, params.name || '', params.navigationId || '', false); + this._page.frameManager.frameCommittedNewDocumentNavigation(params.frameId, params.url, params.name || '', params.navigationId, false); } _onSameDocumentNavigation(params: Protocol.Page.sameDocumentNavigationPayload) { diff --git a/tests/library/defaultbrowsercontext-2.spec.ts b/tests/library/defaultbrowsercontext-2.spec.ts index 516f114a8b141..d583cb0b0bf5a 100644 --- a/tests/library/defaultbrowsercontext-2.spec.ts +++ b/tests/library/defaultbrowsercontext-2.spec.ts @@ -142,6 +142,23 @@ it('should create userDataDir if it does not exist', async ({ createUserDataDir, expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); }); +it('should goto about:blank on relaunched persistent context', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41216' }, +}, async ({ browserType, createUserDataDir }) => { + const userDataDir = await createUserDataDir(); + + const context1 = await browserType.launchPersistentContext(userDataDir); + await context1.pages()[0].goto('about:blank'); + await context1.close(); + + // When relaunching with an existing profile, Firefox session restore can race with the user's goto and cause "interrupted by another navigation". + // This issue is timing-sensitive and might not fire on every run, so rely on CI's --repeat-each matrix for coverage. + const context2 = await browserType.launchPersistentContext(userDataDir); + await context2.pages()[0].goto('about:blank'); + expect(context2.pages()[0].url()).toBe('about:blank'); + await context2.close(); +}); + it('should have default URL when launching browser', async ({ launchPersistent }) => { const { context } = await launchPersistent(); const urls = context.pages().map(page => page.url()); From 954a7680c9bebd96323e299bf01a9ee079b080e9 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 10 Jun 2026 15:13:35 -0700 Subject: [PATCH 2/2] fix(mcp): add missing .catch() on sessionLog write queue (#41234) `SessionLog._sessionFileQueue` chains `appendFile` calls with no `.catch()`. If a single write rejects (disk full, permissions), the chain stays permanently rejected and all future log entries are silently dropped for the rest of the MCP session. Add `.catch(e => debug('pw:tools:error')(e))` to match the pattern in `logFile.ts:55`. This was first introduced in (, 2025-09-03). --- .../src/tools/backend/sessionLog.ts | 4 +- tests/mcp/session-log.spec.ts | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/tools/backend/sessionLog.ts b/packages/playwright-core/src/tools/backend/sessionLog.ts index 48d04986e5d76..f7cb13957b445 100644 --- a/packages/playwright-core/src/tools/backend/sessionLog.ts +++ b/packages/playwright-core/src/tools/backend/sessionLog.ts @@ -17,6 +17,8 @@ import fs from 'fs'; import path from 'path'; +import debug from 'debug'; + import { outputFile } from './context'; import { parseResponse } from './response'; @@ -60,6 +62,6 @@ export class SessionLog { } lines.push(''); - this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n'))); + this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n'))).catch(e => debug('pw:tools:error')(e)); } } diff --git a/tests/mcp/session-log.spec.ts b/tests/mcp/session-log.spec.ts index 8fac3c6296f9f..f18815320e2a3 100644 --- a/tests/mcp/session-log.spec.ts +++ b/tests/mcp/session-log.spec.ts @@ -84,6 +84,53 @@ test('session log should record tool calls', async ({ startClient, server, mcpBr `.trim())); }); +test('session log should recover after write failure', async ({ startClient, server, mcpBrowser }, testInfo) => { + test.skip(mcpBrowser === 'webkit'); + + const { client, stderr } = await startClient({ + args: [ + '--save-session', + '--output-dir', testInfo.outputPath('output'), + ], + }); + + server.setContent('/', `Title`, 'text/html'); + + // First call: logs successfully. + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0]; + const sessionFolder = output.substring('Session: '.length); + const sessionFile = path.join(sessionFolder, 'session.md'); + + // Wait for the first entry to be written. + await expect.poll(() => readSessionLog(sessionFolder)).toContain('browser_navigate'); + + // Make the session file read-only so the next write fails. + await fs.promises.chmod(sessionFile, 0o444); + + // Second call: write fails (EACCES) but the chain should recover. + await client.callTool({ + name: 'browser_click', + arguments: { element: 'Submit button', target: 'e2' }, + }); + + // Restore write permission. + await fs.promises.chmod(sessionFile, 0o644); + + // Third call: without the fix the chain stays rejected and this is never logged. + await client.callTool({ + name: 'browser_click', + arguments: { element: 'Submit button', target: 'e2' }, + }); + + // Verify the third call was logged (chain recovered). + await expect.poll(() => readSessionLog(sessionFolder)).toContain('browser_click'); +}); + async function readSessionLog(sessionFolder: string): Promise { return await fs.promises.readFile(path.join(sessionFolder, 'session.md'), 'utf8').catch(() => ''); }