From 3d86d1cd608bd403ced989fc27f33eacb55c98e2 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Sat, 29 Aug 2026 17:39:46 +0600 Subject: [PATCH 1/2] fix(debug-controller): drop recorded actions when recording session ends The debug controller accumulates recorder actions in a closure and re-sends the full list in every `sourceChanged` event. The list was never cleared when recording was disabled, so a subsequent recording session on the same page leaked the previous session's actions: a late signal (dialog, navigation, popup) re-rendered a stale action as the last one, and clients that diff by count - like the VS Code extension's "Record at cursor" - inserted that stale action into the editor. Clear the accumulated actions when the recorder mode switches to 'none', so each session starts from a clean list. Fixes: https://github.com/microsoft/playwright/issues/42218 --- .../src/server/debugController.ts | 7 ++++ tests/library/debug-controller.spec.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index a9c45a087852a..d56ce4ae83324 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -211,6 +211,13 @@ function wireListeners(recorder: Recorder, debugController: DebugController) { actions.push(action); actionsChanged(); }); + recorder.on(RecorderEvent.ModeChanged, (mode: Mode) => { + // Recording session has ended: drop the accumulated actions, so that the + // next session does not leak them into the emitted source (the client + // would re-insert the stale last action into the editor). + if (mode === 'none') + actions.length = 0; + }); recorder.on(RecorderEvent.SignalAdded, (signal: actions.SignalInContext) => { const lastAction = actions.findLast(a => a.pageGuid === signal.pageGuid); if (lastAction) diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index 784b5e00253c1..a1c78be0faf50 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -272,6 +272,39 @@ test('test', async ({ page }) => { }); }); +test('should not leak actions from the previous recording session', async ({ backend, connectedBrowser }, testInfo) => { + testInfo.annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42218' }); + + const events: any[] = []; + backend.on('sourceChanged', event => events.push(event)); + + // Session 1: "Record at cursor" and record a click. + await backend.setRecorderMode({ mode: 'recording' }, undefined); + const context = await connectedBrowser.newContextForReuse(); + const [page] = context.pages(); + await page.setContent(''); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect.poll(() => events[events.length - 1]?.actions).toEqual([ + ` await page.goto('about:blank');`, + ` await page.getByRole('button', { name: 'Submit' }).click();`, + ]); + + // Stop recording, like "Record at cursor" toggle off. + await backend.setRecorderMode({ mode: 'none' }, undefined); + + // Session 2 on the same page: "Record at cursor" again, record another click. + events.length = 0; + await backend.setRecorderMode({ mode: 'recording' }, undefined); + await page.setContent(''); + await page.getByRole('button', { name: 'Other' }).click(); + + // New session starts from scratch: previous session's actions must not be + // re-sent, otherwise the client re-inserts the stale last action into the editor. + await expect.poll(() => events[events.length - 1]?.actions).toEqual([ + ` await page.getByRole('button', { name: 'Other' }).click();`, + ]); +}); + test('should reset routes before reuse', async ({ server, connectedBrowserFactory }) => { const browser1 = await connectedBrowserFactory(); const context1 = await browser1.newContextForReuse(); From 65a34493067aaaa436f8cc161939228870e752ef Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Tue, 1 Sep 2026 14:11:21 +0600 Subject: [PATCH 2/2] fix(debug-controller): also clear recorded actions on standby, fix firefox test Address review feedback: 'standby' (recorder toolbar pause) is a session end as well, so it clears the accumulated actions too. The firefox failure was a test expectation issue: setContent is recorded as a goto on firefox, so session 2 now avoids navigation; the standby path is covered through the real toolbar toggle since 'standby' is not settable via the protocol. --- .../src/server/debugController.ts | 10 ++++++---- tests/library/debug-controller.spec.ts | 20 ++++++++++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index d56ce4ae83324..7ad8b2d4cc1da 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -212,10 +212,12 @@ function wireListeners(recorder: Recorder, debugController: DebugController) { actionsChanged(); }); recorder.on(RecorderEvent.ModeChanged, (mode: Mode) => { - // Recording session has ended: drop the accumulated actions, so that the - // next session does not leak them into the emitted source (the client - // would re-insert the stale last action into the editor). - if (mode === 'none') + // Recording session has ended (mode switched to 'none' when the client + // stops recording, or 'standby' when the recorder toolbar pauses it): + // drop the accumulated actions, so that the next session does not leak + // them into the emitted source (the client would re-insert the stale + // last action into the editor). + if (mode === 'none' || mode === 'standby') actions.length = 0; }); recorder.on(RecorderEvent.SignalAdded, (signal: actions.SignalInContext) => { diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index a1c78be0faf50..0d70812b58274 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -293,9 +293,13 @@ test('should not leak actions from the previous recording session', async ({ bac await backend.setRecorderMode({ mode: 'none' }, undefined); // Session 2 on the same page: "Record at cursor" again, record another click. + // Avoid navigation here (setContent is recorded as a goto on Firefox), + // the assertion below should only contain the click. events.length = 0; await backend.setRecorderMode({ mode: 'recording' }, undefined); - await page.setContent(''); + await page.evaluate(() => { + document.body.innerHTML = ''; + }); await page.getByRole('button', { name: 'Other' }).click(); // New session starts from scratch: previous session's actions must not be @@ -303,6 +307,20 @@ test('should not leak actions from the previous recording session', async ({ bac await expect.poll(() => events[events.length - 1]?.actions).toEqual([ ` await page.getByRole('button', { name: 'Other' }).click();`, ]); + + // Pause via the recorder toolbar's Record toggle ("standby") must also + // start a new session. 'standby' is not settable via the protocol, only + // through the toolbar UI. + await page.click('x-pw-tool-item.record'); + events.length = 0; + await backend.setRecorderMode({ mode: 'recording' }, undefined); + await page.evaluate(() => { + document.body.innerHTML = ''; + }); + await page.getByRole('button', { name: 'Third' }).click(); + await expect.poll(() => events[events.length - 1]?.actions).toEqual([ + ` await page.getByRole('button', { name: 'Third' }).click();`, + ]); }); test('should reset routes before reuse', async ({ server, connectedBrowserFactory }) => {