From 168a2b8ba6d7426e908e95aab34d703283225a96 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 25 Sep 2026 16:53:30 +0100 Subject: [PATCH 1/2] test(chromium): connect over cdp to a browser with a discarded tab Reproduces https://github.com/microsoft/playwright/issues/41714: a tab discarded by the Memory Saver has no renderer, so renderer-bound initialization commands are never answered and connectOverCDP hangs. The test discards the tab through chrome://discards, which requires the internal_only_uis_enabled pref and the AllowDevtoolsConnectedDiscard feature, since Chrome does not discard tabs with DevTools attached. --- .../library/chromium/connect-over-cdp.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts index 64b4c1be9d543..60350075fafaa 100644 --- a/tests/library/chromium/connect-over-cdp.spec.ts +++ b/tests/library/chromium/connect-over-cdp.spec.ts @@ -42,6 +42,51 @@ test('should connect to an existing cdp session', async ({ browserType, mode }, } }); +test('should connect when an existing page has been discarded', async ({ browserType, createUserDataDir, server }, testInfo) => { + // https://github.com/microsoft/playwright/issues/41714 + const port = 9339 + testInfo.workerIndex; + const userDataDir = await createUserDataDir(); + // chrome://discards is an internal debug page that is only available with this pref. + fs.writeFileSync(path.join(userDataDir, 'Local State'), JSON.stringify({ internal_only_uis_enabled: true })); + const context = await browserType.launchPersistentContext(userDataDir, { + headless: false, + // Emulating the viewport of a discarded tab crashes the browser in + // WebContentsImpl::SetDeviceEmulationSize, because it has no view. + viewport: null, + args: [ + '--remote-debugging-port=' + port, + // Chrome refuses to discard tabs with DevTools attached, and we attach to all of them. + '--enable-features=AllowDevtoolsConnectedDiscard', + ], + }); + try { + const victimUrl = server.PREFIX + '/title.html'; + const victim = await context.newPage(); + await victim.goto(victimUrl); + const discards = await context.newPage(); + await discards.goto('chrome://discards/'); + // Discarding replaces the tab's WebContents, but the url in the table survives. + const row = discards.locator('tr', { has: discards.locator('.tab-url-cell', { hasText: victimUrl }) }); + await row.locator('[is=action-link]', { hasText: 'Urgent Discard' }).click(); + await expect(row).toContainText('discarded'); + // The renderer process is shut down asynchronously after the discard. + await discards.waitForTimeout(3000); + await testInfo.attach('discards rows', { body: (await discards.locator('tr').allInnerTexts()).map(text => text.replace(/\s+/g, ' ')).join('\n') }); + const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + await testInfo.attach('targets', { body: JSON.stringify(targets, null, 2), contentType: 'application/json' }); + + // Connecting should not hang on the discarded page, and the page should not be reported. + const cdpBrowser = await browserType.connectOverCDP({ + endpointURL: `http://127.0.0.1:${port}/`, + }); + const pages = cdpBrowser.contexts()[0].pages(); + expect(pages.map(page => page.url()).sort()).toEqual(['about:blank', 'chrome://discards/']); + await cdpBrowser.close(); + } finally { + await context.close(); + } +}); + test('should cleanup artifacts dir after connectOverCDP disconnects due to ws close', async ({ browserType, toImpl, mode }, testInfo) => { const port = 9339 + testInfo.workerIndex; const browserServer = await browserType.launch({ From 66147466cb47e89e85e417e668857d4c8ac3855d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 25 Sep 2026 17:51:05 +0100 Subject: [PATCH 2/2] test: locate the discards table by roles and text instead of internal class names --- tests/library/chromium/connect-over-cdp.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts index 60350075fafaa..6edb42114b707 100644 --- a/tests/library/chromium/connect-over-cdp.spec.ts +++ b/tests/library/chromium/connect-over-cdp.spec.ts @@ -66,12 +66,12 @@ test('should connect when an existing page has been discarded', async ({ browser const discards = await context.newPage(); await discards.goto('chrome://discards/'); // Discarding replaces the tab's WebContents, but the url in the table survives. - const row = discards.locator('tr', { has: discards.locator('.tab-url-cell', { hasText: victimUrl }) }); - await row.locator('[is=action-link]', { hasText: 'Urgent Discard' }).click(); + const row = discards.getByRole('row', { name: victimUrl }); + await row.getByText('Urgent Discard').click(); await expect(row).toContainText('discarded'); // The renderer process is shut down asynchronously after the discard. await discards.waitForTimeout(3000); - await testInfo.attach('discards rows', { body: (await discards.locator('tr').allInnerTexts()).map(text => text.replace(/\s+/g, ' ')).join('\n') }); + await testInfo.attach('discards rows', { body: (await discards.getByRole('row').allInnerTexts()).map(text => text.replace(/\s+/g, ' ')).join('\n') }); const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); await testInfo.attach('targets', { body: JSON.stringify(targets, null, 2), contentType: 'application/json' });