diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml
index bfd4d6d5fa9d2..07eb51e35d67c 100644
--- a/.github/workflows/publish_release.yml
+++ b/.github/workflows/publish_release.yml
@@ -7,10 +7,8 @@ on:
push:
branches:
- release-*
- tags:
- # TODO: revert this to "published release" once github.ref is set there.
- # See https://github.com/actions/runner/issues/2788 as well.
- - 'v1.61.1'
+ release:
+ types: [published]
jobs:
publish-npm-and-driver:
@@ -49,7 +47,7 @@ jobs:
node utils/build/update_canary_version.js --beta --commit-timestamp
utils/publish_all_packages.sh --beta
- name: "publish release to NPM"
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+ if: github.event_name == 'release' && github.event.action == 'published'
run: utils/publish_all_packages.sh --release
- name: Azure Login
@@ -60,7 +58,7 @@ jobs:
subscription-id: ${{ secrets.AZURE_PW_CDN_SUBSCRIPTION_ID }}
- name: build & publish driver
env:
- AZ_UPLOAD_FOLDER: ${{ startsWith(github.ref, 'refs/tags/v') && 'driver' || 'driver/next' }}
+ AZ_UPLOAD_FOLDER: ${{ github.event_name == 'release' && 'driver' || 'driver/next' }}
run: |
utils/build/build-playwright-driver.sh
utils/build/upload-playwright-driver.sh
@@ -91,7 +89,7 @@ jobs:
env:
GH_SERVICE_ACCOUNT_TOKEN: ${{ steps.app-token.outputs.token }}
- name: Deploy Stable
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+ if: github.event_name == 'release' && github.event.action == 'published'
run: bash utils/build/deploy-trace-viewer.sh --stable
env:
GH_SERVICE_ACCOUNT_TOKEN: ${{ steps.app-token.outputs.token }}
diff --git a/docs/src/api/class-coverage.md b/docs/src/api/class-coverage.md
index 8eae75e24df73..8a5fb7af6824d 100644
--- a/docs/src/api/class-coverage.md
+++ b/docs/src/api/class-coverage.md
@@ -54,9 +54,10 @@ will have `__playwright_evaluation_script__` as their URL.
### option: Coverage.startJSCoverage.resetOnNavigation
* since: v1.11
+* discouraged: Settings this to `false` may still reset on navigations.
- `resetOnNavigation` <[boolean]>
-Whether to reset coverage on every navigation. Defaults to `true`.
+Whether to reset coverage on every navigation. Defaults to `true`. Note that passing `false` does not guarantee that coverage persists through navigations, due to browser architecture limitations.
### option: Coverage.startJSCoverage.reportAnonymousScripts
* since: v1.11
diff --git a/packages/html-reporter/src/tabbedPane.css b/packages/html-reporter/src/tabbedPane.css
index 042ab9f7608d5..d063719c3912b 100644
--- a/packages/html-reporter/src/tabbedPane.css
+++ b/packages/html-reporter/src/tabbedPane.css
@@ -48,11 +48,20 @@
align-items: center;
justify-content: center;
user-select: none;
+ background: none;
+ border: none;
border-bottom: 2px solid transparent;
+ color: inherit;
+ font: inherit;
outline: none;
height: 100%;
}
+.tabbed-pane-tab-element:focus-visible {
+ outline: 1px solid var(--color-accent-fg);
+ outline-offset: -1px;
+}
+
.tabbed-pane-tab-label {
max-width: 250px;
white-space: pre;
diff --git a/packages/html-reporter/src/tabbedPane.tsx b/packages/html-reporter/src/tabbedPane.tsx
index a1e533348b873..55d8f51955f08 100644
--- a/packages/html-reporter/src/tabbedPane.tsx
+++ b/packages/html-reporter/src/tabbedPane.tsx
@@ -37,14 +37,14 @@ export const TabbedPane: React.FunctionComponent<{
{
tabs.map(tab => (
-
setSelectedTab(tab.id)}
id={`${idPrefix}-${tab.id}`}
key={tab.id}
role='tab'
aria-selected={selectedTab === tab.id}>
{tab.title}
-
+
))
}
diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts
index d99727d47640f..c72d3c64918f8 100644
--- a/packages/injected/src/ariaSnapshot.ts
+++ b/packages/injected/src/ariaSnapshot.ts
@@ -15,7 +15,7 @@
*/
import * as aria from '@isomorphic/ariaSnapshot';
-import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
+import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace, truncateDataUrl } from '@isomorphic/stringUtils';
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from '@isomorphic/yaml';
import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils';
@@ -184,7 +184,7 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp
if (ariaNode.role === 'link' && element.hasAttribute('href')) {
const href = element.getAttribute('href')!;
- ariaNode.props['url'] = href;
+ ariaNode.props['url'] = truncateDataUrl(href);
}
if (ariaNode.role === 'textbox' && element.hasAttribute('placeholder') && element.getAttribute('placeholder') !== ariaNode.name) {
diff --git a/packages/isomorphic/selectorParser.ts b/packages/isomorphic/selectorParser.ts
index f37d1ef4c31d8..c5c322cb7a361 100644
--- a/packages/isomorphic/selectorParser.ts
+++ b/packages/isomorphic/selectorParser.ts
@@ -324,7 +324,7 @@ export function parseAttributeSelector(selector: string, allowUnquotedStrings: b
syntaxError('parsing regular expression');
let flags = '';
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
- while (!EOL && next().match(/[dgimsuy]/))
+ while (!EOL && next().match(/[dgimsuvy]/))
flags += eat1();
try {
return new RegExp(source, flags);
diff --git a/packages/isomorphic/stringUtils.ts b/packages/isomorphic/stringUtils.ts
index c4a3476818962..8629b3813db03 100644
--- a/packages/isomorphic/stringUtils.ts
+++ b/packages/isomorphic/stringUtils.ts
@@ -136,6 +136,17 @@ export function trimStringWithEllipsis(input: string, cap: number): string {
return trimString(input, cap, '\u2026');
}
+export function truncateDataUrl(url: string): string {
+ // Data URLs can carry megabytes of base64 payload, which is never useful in
+ // human/AI-facing output. Keep the media type prefix for context, drop the data.
+ if (!url.startsWith('data:'))
+ return url;
+ const comma = url.indexOf(',');
+ if (comma === -1)
+ return url;
+ return url.slice(0, comma + 1) + '\u2026';
+}
+
export function escapeRegExp(s: string) {
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts
index b2150842591d0..0e37993306575 100644
--- a/packages/playwright-client/types/types.d.ts
+++ b/packages/playwright-client/types/types.d.ts
@@ -19024,7 +19024,10 @@ export interface Coverage {
reportAnonymousScripts?: boolean;
/**
- * Whether to reset coverage on every navigation. Defaults to `true`.
+ * **NOTE** Settings this to `false` may still reset on navigations.
+ *
+ * Whether to reset coverage on every navigation. Defaults to `true`. Note that passing `false` does not guarantee
+ * that coverage persists through navigations, due to browser architecture limitations.
*/
resetOnNavigation?: boolean;
}): Promise;
diff --git a/packages/playwright-core/src/server/chromium/chromiumSwitches.ts b/packages/playwright-core/src/server/chromium/chromiumSwitches.ts
index b63cadd1ff68b..aade807fc3e65 100644
--- a/packages/playwright-core/src/server/chromium/chromiumSwitches.ts
+++ b/packages/playwright-core/src/server/chromium/chromiumSwitches.ts
@@ -40,8 +40,6 @@ const disabledFeatures = [
'Translate',
// See https://issues.chromium.org/u/1/issues/435410220
'AutoDeElevate',
- // See https://github.com/microsoft/playwright/issues/37714
- 'RenderDocument',
// Prevents downloading optimization hints on startup.
'OptimizationHints',
// Disables forced sign-in in Edge.
diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts
index dc8e86ef84607..ca307ec5d82df 100644
--- a/packages/playwright-core/src/server/frames.ts
+++ b/packages/playwright-core/src/server/frames.ts
@@ -235,6 +235,7 @@ export class FrameManager {
const frame = this._frames.get(frameId)!;
this.removeChildFramesRecursively(frame);
this._clearWebSockets(frame);
+ const previousUrl = frame._url;
frame._url = url;
frame._name = name;
@@ -269,6 +270,11 @@ export class FrameManager {
if (!initial) {
frame.apiLog(` navigated to "${url}"`);
this._page.frameNavigatedToNewDocument(frame);
+ // Re-number the main frame when it navigates away from a real document, so that aria
+ // refs (fe) minted against the previous document do not accidentally resolve
+ // to elements in the new one.
+ if (frame === this._mainFrame && previousUrl && previousUrl !== 'about:blank')
+ frame.seq = this._allocateFrameSeq();
}
// Restore pending if any - see comments above about keepPending.
frame._setPendingDocument(keepPending);
@@ -501,7 +507,7 @@ export class Frame extends SdkObject {
static Events = FrameEvent;
_id: string;
- readonly seq: number;
+ seq: number;
_firedLifecycleEvents = new Set();
private _firedNetworkIdleSelf = false;
_currentDocument: DocumentInfo;
diff --git a/packages/playwright-core/src/tools/backend/network.ts b/packages/playwright-core/src/tools/backend/network.ts
index 9f33b1540c3c5..ded33eb582207 100644
--- a/packages/playwright-core/src/tools/backend/network.ts
+++ b/packages/playwright-core/src/tools/backend/network.ts
@@ -20,6 +20,7 @@ import * as z from 'zod';
import { getExtensionForMimeType, isTextualMimeType } from '@isomorphic/mimeType';
import { isRegexString } from '@isomorphic/rtti';
+import { truncateDataUrl } from '@isomorphic/stringUtils';
import { defineTool, defineTabTool } from './tool';
@@ -128,7 +129,7 @@ export function isFetch(request: playwright.Request): boolean {
export function renderRequestLine(request: playwright.Request): string {
const response = request.existingResponse();
- let line = `[${request.method().toUpperCase()}] ${request.url()}`;
+ let line = `[${request.method().toUpperCase()}] ${truncateDataUrl(request.url())}`;
if (response)
line += ` => [${response.status()}] ${response.statusText()}`;
else if (request.failure())
@@ -140,7 +141,7 @@ function renderRequestDetails(index: number, request: playwright.Request, skillM
const httpResponse = request.existingResponse();
const responseHeaders = httpResponse?.headers();
const lines: string[] = [];
- lines.push(`#${index} [${request.method().toUpperCase()}] ${request.url()}`);
+ lines.push(`#${index} [${request.method().toUpperCase()}] ${truncateDataUrl(request.url())}`);
lines.push('');
lines.push(' General');
diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts
index b2150842591d0..0e37993306575 100644
--- a/packages/playwright-core/types/types.d.ts
+++ b/packages/playwright-core/types/types.d.ts
@@ -19024,7 +19024,10 @@ export interface Coverage {
reportAnonymousScripts?: boolean;
/**
- * Whether to reset coverage on every navigation. Defaults to `true`.
+ * **NOTE** Settings this to `false` may still reset on navigations.
+ *
+ * Whether to reset coverage on every navigation. Defaults to `true`. Note that passing `false` does not guarantee
+ * that coverage persists through navigations, due to browser architecture limitations.
*/
resetOnNavigation?: boolean;
}): Promise;
diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts
index 5a0702087085d..e1d0737af599b 100644
--- a/packages/playwright/src/reporters/base.ts
+++ b/packages/playwright/src/reporters/base.ts
@@ -86,8 +86,9 @@ class StripAnsiStream extends Writable {
this._target = target;
}
- override _write(chunk: any, encoding: any, callback: any) {
- this._target.write(stripAnsiEscapes(chunk.toString()), callback);
+ override write(chunk: any, encodingOrCallback?: any, callback?: any): boolean {
+ const cb = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback;
+ return this._target.write(stripAnsiEscapes(chunk.toString()), cb);
}
}
diff --git a/tests/library/chromium/js-coverage.spec.ts b/tests/library/chromium/js-coverage.spec.ts
index 18f21b70fd9d5..7acc69386f985 100644
--- a/tests/library/chromium/js-coverage.spec.ts
+++ b/tests/library/chromium/js-coverage.spec.ts
@@ -63,14 +63,6 @@ it('should report multiple scripts', async function({ page, server }) {
expect(coverage[1].url).toContain('/jscoverage/script2.js');
});
-it('should report scripts across navigations when disabled', async function({ page, server }) {
- await page.coverage.startJSCoverage({ resetOnNavigation: false });
- await page.goto(server.PREFIX + '/jscoverage/multiple.html');
- await page.goto(server.EMPTY_PAGE);
- const coverage = await page.coverage.stopJSCoverage();
- expect(coverage.length).toBe(2);
-});
-
it('should NOT report scripts across navigations when enabled', async function({ page, server }) {
await page.coverage.startJSCoverage(); // Enabled by default.
await page.goto(server.PREFIX + '/jscoverage/multiple.html');
diff --git a/tests/mcp/config-resolve.spec.ts b/tests/mcp/config-resolve.spec.ts
index f4cc2c35f26e7..d961bfec20465 100644
--- a/tests/mcp/config-resolve.spec.ts
+++ b/tests/mcp/config-resolve.spec.ts
@@ -248,6 +248,21 @@ test.describe('merge order', () => {
const config = await resolveCLIConfigForMCP({ config: configFile }, emptyEnv);
expect(config.browser.cdpHeaders).toEqual({ Authorization: 'Bearer token-from-file' });
});
+
+ test('env browser.cdpHeaders overrides config file and preserves colons in values', async ({}, testInfo) => {
+ const configFile = testInfo.outputPath('config.json');
+ const fileConfig: Config = {
+ browser: {
+ cdpEndpoint: 'ws://example.invalid',
+ cdpHeaders: { Authorization: 'Bearer token-from-file' },
+ },
+ };
+ await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
+ const config = await resolveCLIConfigForMCP({ config: configFile }, {
+ PLAYWRIGHT_MCP_CDP_HEADERS: 'X-Forwarded-Proto: value:with:colons',
+ });
+ expect(config.browser.cdpHeaders).toEqual({ 'X-Forwarded-Proto': 'value:with:colons' });
+ });
});
// ---------------------------------------------------------------------------
diff --git a/tests/page/page-aria-snapshot-ai.spec.ts b/tests/page/page-aria-snapshot-ai.spec.ts
index 927a3dad967a5..e77b8786ea1a7 100644
--- a/tests/page/page-aria-snapshot-ai.spec.ts
+++ b/tests/page/page-aria-snapshot-ai.spec.ts
@@ -142,6 +142,36 @@ it('should stitch all frame snapshots', async ({ page, server }) => {
}
});
+it('should re-number refs across navigations but not same-document navigations', async ({ page, server }) => {
+ server.setRoute('/one.html', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.end('');
+ });
+ server.setRoute('/two.html', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.end('');
+ });
+
+ // The first committed document keeps the base seq, so the main frame has no prefix.
+ await page.goto(server.PREFIX + '/one.html');
+ const oneRef = (await snapshotForAI(page)).match(/button "One" \[ref=(e\d+)\]/)![1];
+ await expect(page.locator(`aria-ref=${oneRef}`)).toHaveText('One');
+
+ // Cross-document navigation re-numbers the main frame, so its refs gain a frame prefix.
+ await page.goto(server.PREFIX + '/two.html');
+ const twoRef = (await snapshotForAI(page)).match(/button "Two" \[ref=(f\d+e\d+)\]/)![1];
+ await expect(page.locator(`aria-ref=${twoRef}`)).toHaveText('Two');
+
+ // The stale ref from the previous document must not resolve against the new one.
+ const error = await page.locator(`aria-ref=${oneRef}`).normalize().catch(e => e);
+ expect(error.message).toContain(`No element matching aria-ref=${oneRef}`);
+
+ // Same-document navigation keeps refs intact.
+ await page.evaluate(() => history.pushState({}, '', '/pushed.html'));
+ expect(await snapshotForAI(page)).toContain(`button "Two" [ref=${twoRef}]`);
+ await expect(page.locator(`aria-ref=${twoRef}`)).toHaveText('Two');
+});
+
it('should persist iframe references', async ({ page }) => {
await page.setContent(`
@@ -318,6 +348,14 @@ it('should not nest cursor pointer hints', async ({ page }) => {
`);
});
+it('should truncate data url in link', async ({ page }) => {
+ const base64 = Buffer.from('hello
').toString('base64');
+ await page.setContent(`a link`);
+ const snapshot = await snapshotForAI(page);
+ expect(snapshot).toContain('/url: data:text/html;base64,…');
+ expect(snapshot).not.toContain(base64);
+});
+
it('should gracefully fallback when child frame cant be captured', async ({ page, server }) => {
await page.setContent(`
Test
@@ -337,9 +375,8 @@ it('should auto-wait for navigation', async ({ page, server }) => {
page.evaluate(() => window.location.reload()),
snapshotForAI(page)
]);
- expect(snapshot).toContainYaml(`
- - generic [ref=e2]: Hi, I'm frame
- `);
+ // The snapshot races the reload, which may re-number the main frame, so accept any ref.
+ expect(snapshot).toMatch(/- generic \[ref=(?:f\d+)?e\d+\]: Hi, I'm frame/);
});
it('should auto-wait for blocking CSS', async ({ page, server }) => {
diff --git a/tests/page/selectors-get-by.spec.ts b/tests/page/selectors-get-by.spec.ts
index 692de3cc7fc29..de76065eca6c0 100644
--- a/tests/page/selectors-get-by.spec.ts
+++ b/tests/page/selectors-get-by.spec.ts
@@ -286,6 +286,14 @@ it('getByRole escaping', async ({ page }) => {
]);
});
+it('getByRole should accept regexp with v flag', async ({ page }) => {
+ // Regression test for https://github.com/microsoft/playwright/issues/41457
+ await page.setContent(``);
+ await expect(page.getByRole('button', { name: /Click me/v })).toHaveCount(1);
+ await expect(page.getByRole('button', { name: /click me/iv })).toHaveCount(1);
+ await expect(page.getByRole('button', { name: /Missing/v })).toHaveCount(0, { timeout: 1000 });
+});
+
it('getByRole with description', async ({ page }) => {
await page.setContent(`
Alert 1
diff --git a/tests/page/workers.spec.ts b/tests/page/workers.spec.ts
index f7f1b27360649..3da5821b57186 100644
--- a/tests/page/workers.spec.ts
+++ b/tests/page/workers.spec.ts
@@ -365,3 +365,58 @@ it('should support offline', async ({ page, server, browserName }) => {
await page.context().setOffline(false);
await expect.poll(() => worker.evaluate(() => navigator.onLine)).toBe(true);
});
+
+it('should resolve worker script allHeaders in main frame', {
+ annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/39948' },
+}, async function({ page, server, browserName }) {
+ const [request] = await Promise.all([
+ page.waitForEvent('requestfinished', request => request.url() === server.PREFIX + '/worker/worker.js'),
+ page.goto(server.PREFIX + '/worker/worker.html'),
+ ]);
+ const response = await request.response();
+ const requestHeaders = await request.allHeaders();
+ expect(requestHeaders['host']).toBeTruthy();
+ const responseHeaders = await response.allHeaders();
+ expect(responseHeaders['content-type']).toBeTruthy();
+});
+
+it('should resolve worker script allHeaders in iframe', {
+ annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/39948' },
+}, async function({ page, server, browserName }) {
+ it.fixme(browserName === 'chromium', 'https://github.com/microsoft/playwright/issues/39948');
+
+ const [request] = await Promise.all([
+ page.waitForEvent('requestfinished', request => request.url() === server.PREFIX + '/worker/worker.js'),
+ attachFrame(page, 'frame1', server.PREFIX + '/worker/worker.html'),
+ ]);
+ const response = await request.response();
+ const requestHeaders = await request.allHeaders();
+ expect(requestHeaders['host']).toBeTruthy();
+ const responseHeaders = await response.allHeaders();
+ expect(responseHeaders['content-type']).toBeTruthy();
+});
+
+it('should resolve worker script allHeaders in nested worker inside iframe', {
+ annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/39948' },
+}, async function({ page, server, browserName }) {
+ it.fixme(browserName === 'webkit', 'cannot evaluate in nested worker');
+ it.fixme(browserName === 'firefox', 'nested worker script request is not reported at all');
+ it.fixme(browserName === 'chromium', 'https://github.com/microsoft/playwright/issues/39948');
+
+ const [worker] = await Promise.all([
+ page.waitForEvent('worker'),
+ attachFrame(page, 'frame1', server.PREFIX + '/worker/worker.html'),
+ ]);
+
+ const url = server.PREFIX + '/worker/worker.js';
+ const [request] = await Promise.all([
+ page.waitForEvent('requestfinished', request => request.url() === url),
+ worker.evaluate(url => {
+ (self as any).w = new Worker(url);
+ }, url),
+ ]);
+
+ const response = await request.response();
+ const headers = await response.allHeaders();
+ expect(headers['content-type']).toBeTruthy();
+});
diff --git a/utils/build/build-playwright-driver.sh b/utils/build/build-playwright-driver.sh
index 652b85248b694..da2c8ff30de34 100755
--- a/utils/build/build-playwright-driver.sh
+++ b/utils/build/build-playwright-driver.sh
@@ -4,7 +4,7 @@ set -x
trap "cd $(pwd -P)" EXIT
SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)"
-NODE_VERSION="24.17.0" # autogenerated via ./update-playwright-node.mjs
+NODE_VERSION="24.18.0" # autogenerated via ./update-playwright-node.mjs
cd "$(dirname "$0")"
PACKAGE_VERSION=$(node -p "require('../../package.json').version")