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, + }); 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

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