From 1ddd4f833a9fbd8145d24b7fa6042eccab55d581 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Aug 2026 22:32:21 +0200 Subject: [PATCH 1/5] fix(trace-viewer): remove speculative 1s isUnderTest startup delay (#42071) --- packages/trace-viewer/src/index.tsx | 2 -- packages/trace-viewer/src/uiMode.tsx | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/trace-viewer/src/index.tsx b/packages/trace-viewer/src/index.tsx index 671a2235ae2c6..933e3fed2105b 100644 --- a/packages/trace-viewer/src/index.tsx +++ b/packages/trace-viewer/src/index.tsx @@ -26,8 +26,6 @@ import { LiveWorkbenchLoader } from './ui/liveWorkbenchLoader'; applyTheme(); if (window.location.protocol !== 'file:') { - if (queryParams.get('isUnderTest') === 'true') - await new Promise(f => setTimeout(f, 1000)); if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`); navigator.serviceWorker.register('sw.bundle.js'); diff --git a/packages/trace-viewer/src/uiMode.tsx b/packages/trace-viewer/src/uiMode.tsx index 5dac2082e81ee..24db23f933106 100644 --- a/packages/trace-viewer/src/uiMode.tsx +++ b/packages/trace-viewer/src/uiMode.tsx @@ -23,8 +23,6 @@ import { UIModeView } from './ui/uiModeView'; (async () => { applyTheme(); if (window.location.protocol !== 'file:') { - if (window.location.href.includes('isUnderTest=true')) - await new Promise(f => setTimeout(f, 1000)); if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the website (${window.location}) via HTTPS or localhost.`); navigator.serviceWorker.register('sw.bundle.js'); From c3749105bd12784567ad907e0ecddfe2f2124c6d Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Aug 2026 22:33:36 +0200 Subject: [PATCH 2/5] devops: extract reusable PR CI triage workflow (#42090) --- .github/workflows/create_test_report.yml | 108 +----------------- .github/workflows/pr-ci-triage.yml | 134 +++++++++++++++++++++++ 2 files changed, 139 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/pr-ci-triage.yml diff --git a/.github/workflows/create_test_report.yml b/.github/workflows/create_test_report.yml index 0bc76438dac5d..f6aed35520254 100644 --- a/.github/workflows/create_test_report.yml +++ b/.github/workflows/create_test_report.yml @@ -116,109 +116,11 @@ jobs: triage: needs: merge-reports if: ${{ needs.merge-reports.outputs.has_failures == 'true' && needs.merge-reports.outputs.pr_number && needs.merge-reports.outputs.triage_allowed == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 permissions: + contents: read + actions: read + pull-requests: write copilot-requests: write - outputs: - has_draft: ${{ steps.triage.outputs.has_draft }} + uses: ./.github/workflows/pr-ci-triage.yml + with: pr_number: ${{ needs.merge-reports.outputs.pr_number }} - env: - PR_NUMBER: ${{ needs.merge-reports.outputs.pr_number }} - GH_TOKEN: ${{ github.token }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - - - name: Install Copilot CLI - run: npm install -g @github/copilot - - - name: Triage failures with Copilot CLI - id: triage - env: - COPILOT_GITHUB_TOKEN: ${{ github.token }} - run: | - mkdir -p output - PROMPT=$(cat <> "$GITHUB_OUTPUT" - else - echo "has_draft=false" >> "$GITHUB_OUTPUT" - fi - - - name: Add session transcript to job summary - if: ${{ always() }} - run: | - { - echo "## CI triage session transcript (PR #$PR_NUMBER)" - echo '' - cat "output/copilot-session.md" 2>/dev/null || echo "(no transcript)" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Upload output - if: ${{ always() }} - uses: actions/upload-artifact@v4 - with: - name: ci-triage-${{ needs.merge-reports.outputs.pr_number }} - path: output/triage.md - if-no-files-found: warn - - post: - needs: triage - if: needs.triage.outputs.has_draft == 'true' - runs-on: ubuntu-latest - permissions: - pull-requests: write - env: - PR_NUMBER: ${{ needs.triage.outputs.pr_number }} - GH_TOKEN: ${{ github.token }} - WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Download triage output - uses: actions/download-artifact@v4 - with: - name: ci-triage-${{ needs.triage.outputs.pr_number }} - path: output - - - name: Post triage comment - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const { collapsePreviousComments } = require('./tests/config/postReportComment'); - const sentinel = ``; - const prNumber = +process.env.PR_NUMBER; - await collapsePreviousComments(github, context, prNumber, sentinel); - - const triage = fs.readFileSync('output/triage.md', 'utf8'); - const body = `${triage}\n\nTriaged by the Playwright bot - [agent run](${process.env.WORKFLOW_URL})\n${sentinel}\n`; - await github.rest.issues.createComment({ - ...context.repo, - issue_number: prNumber, - body, - }); diff --git a/.github/workflows/pr-ci-triage.yml b/.github/workflows/pr-ci-triage.yml new file mode 100644 index 0000000000000..07c9c7b32a180 --- /dev/null +++ b/.github/workflows/pr-ci-triage.yml @@ -0,0 +1,134 @@ +# Reusable CI triage for PR test failures. Callable from other repos +# (e.g. microsoft/playwright-browsers) via workflow_call. +name: PR CI Triage + +on: + workflow_call: + inputs: + pr_number: + description: 'PR number to triage' + required: true + type: string + +permissions: {} + +jobs: + triage: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + actions: read + pull-requests: read + copilot-requests: write + outputs: + has_draft: ${{ steps.triage.outputs.has_draft }} + env: + PR_NUMBER: ${{ inputs.pr_number }} + TARGET_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + repository: microsoft/playwright + ref: main + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Install Copilot CLI + run: npm install -g @github/copilot + + - name: Triage failures with Copilot CLI + id: triage + env: + COPILOT_GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p output + PROMPT=$(cat <> "$GITHUB_OUTPUT" + else + echo "has_draft=false" >> "$GITHUB_OUTPUT" + fi + + - name: Add session transcript to job summary + if: ${{ always() }} + run: | + { + echo "## CI triage session transcript (PR #$PR_NUMBER on $TARGET_REPO)" + echo '' + cat output/copilot-session.md 2>/dev/null || echo "(no transcript)" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload triage draft + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: ci-triage-${{ inputs.pr_number }} + path: output/triage.md + if-no-files-found: warn + + post: + needs: triage + if: ${{ needs.triage.outputs.has_draft == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + env: + PR_NUMBER: ${{ inputs.pr_number }} + GH_TOKEN: ${{ github.token }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPORT_NAME: ${{ github.event.workflow_run.name || github.workflow }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + repository: microsoft/playwright + ref: main + + - name: Download triage draft + uses: actions/download-artifact@v4 + with: + name: ci-triage-${{ inputs.pr_number }} + path: output + + - name: Post triage comment + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const { collapsePreviousComments } = require('./tests/config/postReportComment'); + const sentinel = ``; + const prNumber = +process.env.PR_NUMBER; + await collapsePreviousComments(github, context, prNumber, sentinel); + const triage = fs.readFileSync('output/triage.md', 'utf8'); + const body = `${triage}\n\nTriaged by the Playwright bot - [agent run](${process.env.WORKFLOW_URL})\n${sentinel}\n`; + await github.rest.issues.createComment({ + ...context.repo, + issue_number: prNumber, + body, + }); From 19630696cbe095ca632aba7d5599d758ef4b4852 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 3 Aug 2026 15:36:37 -0700 Subject: [PATCH 3/5] feat(cli): add -g flag to install skills globally (#42099) --- docs/src/getting-started-cli.md | 6 +++++ .../src/tools/cli-client/output.ts | 11 ++++++++ .../src/tools/cli-client/program.ts | 16 ++++++++++-- .../src/tools/cli-daemon/commands.ts | 1 + .../src/tools/cli-daemon/program.ts | 26 ++++++++++++------- .../src/tools/utils/installSkills.ts | 8 +++--- tests/mcp/cli-misc.spec.ts | 26 +++++++++++++++++++ 7 files changed, 79 insertions(+), 15 deletions(-) diff --git a/docs/src/getting-started-cli.md b/docs/src/getting-started-cli.md index e57793372497d..6c99fccbe5b36 100644 --- a/docs/src/getting-started-cli.md +++ b/docs/src/getting-started-cli.md @@ -42,6 +42,12 @@ Coding agents like Claude Code and GitHub Copilot can use locally installed skil playwright-cli install --skills ``` +To share the skills across all your projects, add the `-g` flag to install them into your home directory (`~/.claude/skills` or, with `--skills=agents`, `~/.agents/skills`): + +```bash +playwright-cli install --skills -g +``` + ### Skills-less operation You can also point your agent at the CLI directly and let it discover commands on its own: diff --git a/packages/playwright-core/src/tools/cli-client/output.ts b/packages/playwright-core/src/tools/cli-client/output.ts index 1972f1862adf6..7dc38f04b52da 100644 --- a/packages/playwright-core/src/tools/cli-client/output.ts +++ b/packages/playwright-core/src/tools/cli-client/output.ts @@ -57,6 +57,7 @@ export interface Output { errorDetachNotAttached(session: string): never; errorBrowserNotOpenForTool(session: string): never; errorAttachNoTarget(): never; + errorInstallGlobalRequiresSkills(): never; list(data: ListData): void; closeAll(sessions: string[]): void; @@ -126,6 +127,11 @@ export class TextOutput implements Output { return process.exit(1); } + errorInstallGlobalRequiresSkills(): never { + console.error(`Error: --global requires --skills`); + return process.exit(1); + } + list({ all, browsers, servers, channelSessions }: ListData): void { const byWorkspace = new Map(); for (const browser of browsers) { @@ -307,6 +313,11 @@ export class JsonOutput implements Output { return process.exit(1); } + errorInstallGlobalRequiresSkills(): never { + this._emit({ isError: true, error: `--global requires --skills` }); + return process.exit(1); + } + list({ all, browsers, servers, channelSessions }: ListData): void { const payload: Record = { browsers }; if (all) { diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts index 6a087c48c1653..ef0e82a34fb8b 100644 --- a/packages/playwright-core/src/tools/cli-client/program.ts +++ b/packages/playwright-core/src/tools/cli-client/program.ts @@ -65,8 +65,9 @@ const globalOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions))[] = [ 'session', ]; -const booleanOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions & { all?: boolean }))[] = [ +const booleanOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions & { all?: boolean, g?: boolean }))[] = [ 'all', + 'g', 'help', 'json', 'raw', @@ -85,6 +86,11 @@ export async function program(options?: { embedderVersion?: string}) { args.session = args.s; delete args.s; } + // Normalize -g alias to --global + if (args.g) { + args.global = true; + delete args.g; + } const output: Output = args.json ? new JsonOutput() : new TextOutput(); const commandName = args._?.[0]; @@ -196,6 +202,8 @@ export async function program(options?: { embedderVersion?: string}) { return; } case 'install': + if (args.global && !args.skills) + output.errorInstallGlobalRequiresSkills(); await runInitWorkspace(args, output); output.installed(); return; @@ -315,7 +323,11 @@ async function runInSessionOrStop(entry: SessionFile, clientInfo: ClientInfo, ar async function runInitWorkspace(args: MinimistArgs, output: Output) { const cliPath = libPath('entry', 'cliDaemon.js'); - const daemonArgs: string[] = [cliPath, '--init-workspace', ...(args.skills ? ['--init-skills', String(args.skills)] : [])]; + const daemonArgs: string[] = [ + cliPath, + '--init-workspace', + ...(args.skills ? [args.global ? '--init-skills-global' : '--init-skills', String(args.skills)] : []), + ]; await new Promise((resolve, reject) => { const child = spawn(process.execPath, daemonArgs, { stdio: output.installStdio(), diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 53cd5642852fd..f1312ee5f958c 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -1114,6 +1114,7 @@ const install = declareCommand({ args: z.object({}), options: z.object({ skills: z.string().optional().describe('Install skills, possible values: claude (default), agents.'), + global: z.boolean().optional().describe('Install skills into the home directory instead of the workspace (alias: -g). Requires --skills.'), }), toolName: '', toolParams: () => ({}), diff --git a/packages/playwright-core/src/tools/cli-daemon/program.ts b/packages/playwright-core/src/tools/cli-daemon/program.ts index b57ce32118a45..07c216829a998 100644 --- a/packages/playwright-core/src/tools/cli-daemon/program.ts +++ b/packages/playwright-core/src/tools/cli-daemon/program.ts @@ -45,10 +45,11 @@ export function decorateProgram(program: Command) { .option('--endpoint ', 'attach to a running Playwright browser endpoint') .option('--init-workspace', 'initialize workspace') .option('--init-skills ', 'install skills for the given agent type ("claude" or "agents")') + .option('--init-skills-global ', 'install skills for the given agent type ("claude" or "agents") into the home directory') .action(async (sessionName: string, options: any) => { if (options.initWorkspace) { - await initWorkspace(options.initSkills); + await initWorkspace(options.initSkills, options.initSkillsGlobal); return; } @@ -85,16 +86,20 @@ function globalConfigFile(): string { return path.join(process.env['PWTEST_CLI_GLOBAL_CONFIG'] ?? os.homedir(), '.playwright', 'cli.config.json'); } -export async function initWorkspace(initSkills: string | undefined) { - const cwd = process.cwd(); - const playwrightDir = path.join(cwd, '.playwright'); - await fs.promises.mkdir(playwrightDir, { recursive: true }); - console.log(`✅ Workspace initialized at \`${cwd}\`.`); +export async function initWorkspace(initSkills: string | undefined, initSkillsGlobal?: string) { + const globalSkills = !!initSkillsGlobal; + if (!globalSkills) { + const cwd = process.cwd(); + const playwrightDir = path.join(cwd, '.playwright'); + await fs.promises.mkdir(playwrightDir, { recursive: true }); + console.log(`✅ Workspace initialized at \`${cwd}\`.`); + } - if (initSkills) { - const target = initSkills === 'agents' ? 'agents' : 'claude'; + const skills = initSkillsGlobal ?? initSkills; + if (skills) { + const target = skills === 'agents' ? 'agents' : 'claude'; try { - await installSkills(['playwright-cli'], target); + await installSkills(['playwright-cli'], target, { global: globalSkills }); } catch (error) { console.error('❌', error instanceof Error ? error.message : error); // eslint-disable-next-line no-restricted-properties @@ -102,7 +107,8 @@ export async function initWorkspace(initSkills: string | undefined) { } } - await ensureConfiguredBrowserInstalled(); + if (!globalSkills) + await ensureConfiguredBrowserInstalled(); } async function ensureConfiguredBrowserInstalled() { diff --git a/packages/playwright-core/src/tools/utils/installSkills.ts b/packages/playwright-core/src/tools/utils/installSkills.ts index 43d4978a690f3..4bcea607c265f 100644 --- a/packages/playwright-core/src/tools/utils/installSkills.ts +++ b/packages/playwright-core/src/tools/utils/installSkills.ts @@ -17,6 +17,7 @@ /* eslint-disable no-console */ import fs from 'fs'; +import os from 'os'; import path from 'path'; import { libPath } from '../../package'; @@ -26,14 +27,15 @@ export const allSkills = ['playwright-cli', 'playwright-component-testing', 'pla export type SkillName = typeof allSkills[number]; export type SkillTarget = 'claude' | 'agents'; -export async function installSkills(skills: readonly SkillName[], target: SkillTarget = 'claude') { +export async function installSkills(skills: readonly SkillName[], target: SkillTarget = 'claude', options?: { global?: boolean }) { const cwd = process.cwd(); + const baseDir = options?.global ? os.homedir() : cwd; for (const skill of skills) { const sourceDir = libPath('tools', 'skills', skill); if (!fs.existsSync(sourceDir)) throw new Error(`Skill source directory not found: ${sourceDir}`); - const destDir = path.join(cwd, `.${target}`, 'skills', skill); + const destDir = path.join(baseDir, `.${target}`, 'skills', skill); await fs.promises.cp(sourceDir, destDir, { recursive: true }); - console.log(`✅ Skill installed to \`${path.relative(cwd, destDir)}\`.`); + console.log(`✅ Skill installed to \`${options?.global ? destDir : path.relative(cwd, destDir)}\`.`); } } diff --git a/tests/mcp/cli-misc.spec.ts b/tests/mcp/cli-misc.spec.ts index 67ba1e004040f..750f34c1a5fe5 100644 --- a/tests/mcp/cli-misc.spec.ts +++ b/tests/mcp/cli-misc.spec.ts @@ -60,6 +60,32 @@ test('install workspace w/--skills=agents', async ({ cli }, testInfo) => { expect(fs.existsSync(skillFile)).toBe(true); }); +test('install w/--skills -g installs into the home directory', async ({ cli }, testInfo) => { + const fakeHome = testInfo.outputPath('fake-home'); + await fs.promises.mkdir(fakeHome, { recursive: true }); + const { output } = await cli('install', '--skills', '-g', { env: { HOME: fakeHome, USERPROFILE: fakeHome } }); + expect(output).toContain('Skill installed to'); + expect(output).not.toContain('Workspace initialized'); + + const skillFile = path.join(fakeHome, '.claude', 'skills', 'playwright-cli', 'SKILL.md'); + expect(fs.existsSync(skillFile)).toBe(true); +}); + +test('install w/--skills=agents --global installs into the home directory', async ({ cli }, testInfo) => { + const fakeHome = testInfo.outputPath('fake-home'); + await fs.promises.mkdir(fakeHome, { recursive: true }); + await cli('install', '--skills=agents', '--global', { env: { HOME: fakeHome, USERPROFILE: fakeHome } }); + + const skillFile = path.join(fakeHome, '.agents', 'skills', 'playwright-cli', 'SKILL.md'); + expect(fs.existsSync(skillFile)).toBe(true); +}); + +test('install -g without --skills errors', async ({ cli }) => { + const result = await cli('install', '-g'); + expect(result.exitCode).toBe(1); + expect(result.error).toContain('--global requires --skills'); +}); + test('install handles browser detection', async ({ cli }) => { const { output } = await cli('install'); // Verify that one of the browser detection outcomes occurred From f8b14a311edb17eb07aabf380fed4d619bd1b8ab Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 3 Aug 2026 15:36:47 -0700 Subject: [PATCH 4/5] feat(mcp): add snapshot.boxes config to include bounding boxes in snapshots (#42102) --- .../src/tools/backend/context.ts | 1 + .../src/tools/backend/response.ts | 3 ++- .../playwright-core/src/tools/mcp/config.d.ts | 6 +++++ .../playwright-core/src/tools/mcp/config.ts | 3 ++- .../src/tools/mcp/configIni.ts | 1 + .../playwright-core/src/tools/mcp/program.ts | 1 + tests/mcp/snapshot-mode.spec.ts | 26 +++++++++++++++++++ 7 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 71598b3c1d01f..aa8545962e6f2 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -50,6 +50,7 @@ export type ContextConfig = { secrets?: Record; snapshot?: { mode?: 'full' | 'none'; + boxes?: boolean; }; testIdAttribute?: string; timeouts?: { diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index 725edcdaaff78..c29732af5b6da 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -145,13 +145,14 @@ export class Response { setIncludeSnapshot() { this._includeSnapshot = this._context.config.snapshot?.mode ?? 'full'; + this._includeSnapshotBoxes = this._context.config.snapshot?.boxes; } setIncludeFullSnapshot(includeSnapshotFileName?: string, root?: playwright.Locator, depth?: number, boxes?: boolean) { this._includeSnapshot = 'explicit'; this._includeSnapshotFileName = includeSnapshotFileName; this._includeSnapshotDepth = depth; - this._includeSnapshotBoxes = boxes; + this._includeSnapshotBoxes = boxes ?? this._context.config.snapshot?.boxes; this._includeSnapshotRoot = root; } diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 487f7fac05a5a..9dbda16a1f9ac 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -227,6 +227,12 @@ export type Config = { * When taking snapshots for responses, specifies the mode to use. */ mode?: 'full' | 'none'; + + /** + * Whether to include each element's bounding box as [box=x,y,width,height] in snapshots. + * Coordinates are viewport-relative, in CSS pixels (Element.getBoundingClientRect). + */ + boxes?: boolean; }; /** diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index fd858938cb7fd..85f634a850f80 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -69,6 +69,7 @@ export type CLIOptions = { saveSession?: boolean; secrets?: Record; sharedBrowserContext?: boolean; + snapshotBoxes?: boolean; snapshotMode?: 'full' | 'none'; storageState?: string; testIdAttribute?: string; @@ -372,7 +373,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s saveSession: cliOptions.saveSession, secrets: cliOptions.secrets, sharedBrowserContext: cliOptions.sharedBrowserContext, - snapshot: cliOptions.snapshotMode ? { mode: cliOptions.snapshotMode } : undefined, + snapshot: cliOptions.snapshotMode || cliOptions.snapshotBoxes !== undefined ? { mode: cliOptions.snapshotMode, boxes: cliOptions.snapshotBoxes } : undefined, outputDir: cliOptions.outputDir, outputMaxSize: cliOptions.outputMaxSize, imageResponses: cliOptions.imageResponses, diff --git a/packages/playwright-core/src/tools/mcp/configIni.ts b/packages/playwright-core/src/tools/mcp/configIni.ts index c8dc90ed58bf3..7d71b7d8f2546 100644 --- a/packages/playwright-core/src/tools/mcp/configIni.ts +++ b/packages/playwright-core/src/tools/mcp/configIni.ts @@ -185,4 +185,5 @@ const longhandTypes: Record = { // snapshot 'snapshot.mode': 'string', + 'snapshot.boxes': 'boolean', }; diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 9db1980820257..29b2e6ba33925 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -69,6 +69,7 @@ export function decorateMCPCommand(command: Command) { .option('--save-session', 'Whether to save the Playwright MCP session into the output directory.') .option('--secrets ', 'path to a file containing secrets in the dotenv format', dotenvFileLoader) .option('--shared-browser-context', 'reuse the same browser context between all connected HTTP clients.') + .option('--snapshot-boxes', 'include each element\'s bounding box as [box=x,y,width,height] in snapshots. Coordinates are viewport-relative, in CSS pixels.') .option('--snapshot-mode ', 'when taking snapshots for responses, specifies the mode to use. Can be "full" or "none". Default is "full".') .option('--storage-state ', 'path to the storage state file for isolated sessions.') .option('--test-id-attribute ', 'specify the attribute to use for test ids, defaults to "data-testid"') diff --git a/tests/mcp/snapshot-mode.spec.ts b/tests/mcp/snapshot-mode.spec.ts index e96689ddc5e0b..2b6bafa1e5155 100644 --- a/tests/mcp/snapshot-mode.spec.ts +++ b/tests/mcp/snapshot-mode.spec.ts @@ -96,6 +96,32 @@ test('should not inline console messages with --snapshot-mode=none', async ({ st }); }); +test('should respect --snapshot-boxes', async ({ startClient, server }) => { + server.setContent('/', ` + + + `, 'text/html'); + + const { client } = await startClient({ + args: ['--snapshot-boxes'], + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + })).toHaveResponse({ + snapshot: expect.stringContaining(`- button "click" [ref=e1] [box=100,50,80,40]`), + }); + + expect(await client.callTool({ + name: 'browser_snapshot', + })).toHaveResponse({ + inlineSnapshot: expect.stringContaining(`- button "click" [ref=e1] [box=100,50,80,40]`), + }); +}); + test('should respect snapshot[filename]', async ({ client, server }, testInfo) => { server.setContent('/', ``, 'text/html'); From ea4ea1f658cba38ead61ab6c1cf80ce519d9366d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 3 Aug 2026 15:37:30 -0700 Subject: [PATCH 5/5] feat(mcp): emit structured snapshot in --json responses (#42098) --- docs/src/api/class-locator.md | 55 ++++++ docs/src/api/class-page.md | 37 ++++ packages/injected/src/ariaSnapshot.ts | 68 +++++++ packages/injected/src/injectedScript.ts | 14 +- packages/isomorphic/ariaSnapshot.ts | 9 + packages/isomorphic/protocolMetainfo.ts | 1 + packages/playwright-client/types/types.d.ts | 116 ++++++++++++ .../playwright-core/src/client/channels.d.ts | 16 ++ .../playwright-core/src/client/locator.ts | 5 + packages/playwright-core/src/client/page.ts | 5 + .../playwright-core/src/server/channels.d.ts | 16 ++ .../src/server/dispatchers/frameDispatcher.ts | 4 + packages/playwright-core/src/server/frames.ts | 14 +- packages/playwright-core/src/server/page.ts | 60 ++++++ .../src/tools/backend/response.ts | 30 ++- .../playwright-core/src/tools/backend/tab.ts | 33 +++- packages/playwright-core/types/types.d.ts | 116 ++++++++++++ packages/protocol/spec/frame.yml | 15 ++ packages/protocol/src/validator.ts | 9 + tests/mcp/cli-json.spec.ts | 34 +++- tests/page/page-aria-snapshot-json.spec.ts | 173 ++++++++++++++++++ 21 files changed, 805 insertions(+), 25 deletions(-) create mode 100644 tests/page/page-aria-snapshot-json.spec.ts diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index c252b318fdd91..7a6d5957ce2de 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -239,6 +239,61 @@ When `true`, appends each element's bounding box as `[box=x,y,width,height]` to relative to the viewport, in CSS pixels, as returned by [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect). Defaults to `false`. +## async method: Locator.ariaSnapshotJSON +* since: v1.63 +* langs: js +- returns: <[Serializable]> + +Captures the aria snapshot of the given element as a free form JSON object. + +**Usage** + +```js +await page.getByRole('list').ariaSnapshotJSON(); +``` + +**Details** + +This method returns the same tree as [`method: Locator.ariaSnapshot`], serialized as a JSON value instead of YAML markup. +The result is a list of nodes, each node being either a plain string with static text, or an object with the following properties: +* `role` <[string]> Aria role of the element. +* `name` <[string]> Accessible name of the element, if any. +* `text` <[string]> Text content of the element, when it is the only child. +* `children` <[Array]> Child nodes and text fragments. +* Boolean and value properties for element state flags: `checked`, `disabled`, `expanded`, `active`, `invalid`, `level`, `pressed` and `selected`. +* Additional element properties, for example `url` for links and `placeholder` for text boxes. +* `ref` <[string]> Element reference for AI-optimized snapshots. +* `cursor` <[string]> Set to `"pointer"` for clickable elements in AI-optimized snapshots. +* `box` <[Object]> Bounding box of the element when [`option: Locator.ariaSnapshotJSON.boxes`] is set. + +### option: Locator.ariaSnapshotJSON.mode +* since: v1.63 +- `mode` <[AriaSnapshotMode]<"ai"|"default">> + +When set to `"ai"`, returns a snapshot optimized for AI consumption. Defaults to `"default"`. See details in [`method: Locator.ariaSnapshot`]. + +### option: Locator.ariaSnapshotJSON.timeout = %%-input-timeout-%% +* since: v1.63 + +### option: Locator.ariaSnapshotJSON.timeout = %%-input-timeout-js-%% +* since: v1.63 + +### option: Locator.ariaSnapshotJSON.signal = %%-input-signal-%% + +### option: Locator.ariaSnapshotJSON.depth +* since: v1.63 +- `depth` <[int]> + +When specified, limits the depth of the snapshot. + +### option: Locator.ariaSnapshotJSON.boxes +* since: v1.63 +- `boxes` <[boolean]> + +When `true`, includes each element's bounding box as a `box` property with `x`, `y`, `width` and `height`. Coordinates are +relative to the viewport, in CSS pixels, as returned by [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect). +Defaults to `false`. + ## async method: Locator.blur * since: v1.28 diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 423fc97eefcef..9cb83bef41cdf 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -4427,6 +4427,43 @@ When `true`, appends each element's bounding box as `[box=x,y,width,height]` to relative to the viewport, in CSS pixels, as returned by [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect). Defaults to `false`. +## async method: Page.ariaSnapshotJSON +* since: v1.63 +* langs: js +- returns: <[Serializable]> + +Captures the aria snapshot of the page as a free form JSON object. +Returns the same tree as [`method: Page.ariaSnapshot`], serialized as a JSON value instead of YAML markup. +See [`method: Locator.ariaSnapshotJSON`] for the details of the format. + +### option: Page.ariaSnapshotJSON.mode +* since: v1.63 +- `mode` <[AriaSnapshotMode]<"ai"|"default">> + +When set to `"ai"`, returns a snapshot optimized for AI consumption: including element references like `[ref=e2]` and snapshots of ` + `); + const json = await page.ariaSnapshotJSON({ mode: 'ai' }) as NodeJSON[]; + const iframe = findNode(json, node => node.role === 'iframe'); + expect(iframe?.ref).toBeTruthy(); + const button = findNode(iframe!.children!, node => node.role === 'button'); + expect(button?.name).toBe('In frame'); + expect(button?.ref).toMatch(/^f\d+e\d+$/); +}); + +it('should limit depth', async ({ page }) => { + await page.setContent(`
`); + expect(await page.ariaSnapshotJSON({ depth: 1 })).toEqual([ + { + role: 'list', + children: [ + { role: 'listitem' }, + ], + }, + ]); +}); + +it('should include boxes when requested', async ({ page }) => { + await page.setContent(``); + const json = await page.ariaSnapshotJSON({ boxes: true }) as NodeJSON[]; + const button = findNode(json, node => node.role === 'button'); + expect(button?.box).toEqual({ + x: expect.any(Number), + y: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }); + expect(button!.box.width).toBeGreaterThan(0); + expect(button!.box.height).toBeGreaterThan(0); +}); + +it('should snapshot a locator', async ({ page }) => { + await page.setContent(` +

title

+
    +
  • one
  • +
  • two
  • +
+ `); + expect(await page.locator('ul').ariaSnapshotJSON()).toEqual([ + { + role: 'list', + children: [ + { role: 'listitem', text: 'one' }, + { role: 'listitem', text: 'two' }, + ], + }, + ]); +});