Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actions/run-test/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,8 @@ runs:
with:
report_dir: blob-report
job_name: ${{ inputs.bot-name }}-${{ inputs.shard-index }}
- name: Upload parquet report
if: ${{ !cancelled() && env.PLAYWRIGHT_SETUP_COMPLETE == 'true' }}
uses: ./.github/actions/upload-parquet-report
with:
job_name: ${{ inputs.bot-name }}-${{ inputs.shard-index }}
21 changes: 21 additions & 0 deletions .github/actions/upload-parquet-report/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: 'Upload parquet report'
description: 'Upload the parquet test-results report to GitHub artifacts'
inputs:
report_file:
description: 'Path to the parquet report file'
required: false
default: 'test-results/test-results.parquet'
job_name:
description: 'Unique job name'
required: true
runs:
using: "composite"
steps:
- name: Upload parquet report to GitHub
if: ${{ hashFiles(inputs.report_file) != '' }}
uses: actions/upload-artifact@v7
with:
name: parquet-report-${{ inputs.job_name }}
path: ${{ inputs.report_file }}
retention-days: 7
overwrite: true
6 changes: 6 additions & 0 deletions .github/workflows/tests_bidi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ jobs:
path: test-results/report.json
retention-days: 7

- name: Upload parquet report
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-parquet-report
with:
job_name: ${{ matrix.channel }}

- name: Azure Login
if: ${{ !cancelled() && github.ref == 'refs/heads/main' }}
uses: azure/login@v3
Expand Down
23 changes: 22 additions & 1 deletion .github/workflows/tests_docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,17 @@ jobs:
run: docker exec --workdir /home/pwuser/playwright docker-tests npm run build

- name: "Run @smoke tests inside docker"
run: docker exec --workdir /home/pwuser/playwright docker-tests xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" npm run test -- --grep "@smoke"
run: |
docker exec \
--env GITHUB_RUN_ID \
--env GITHUB_RUN_ATTEMPT \
--env GITHUB_WORKFLOW \
--env GITHUB_EVENT_NAME \
--env GITHUB_REF \
--env GITHUB_REF_NAME \
--env GITHUB_HEAD_REF \
--env GITHUB_SHA \
--workdir /home/pwuser/playwright docker-tests xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" npm run test -- --grep "@smoke"

- name: Azure Login
if: ${{ !cancelled() && github.event_name == 'push' && github.repository == 'microsoft/playwright' }}
Expand Down Expand Up @@ -135,3 +145,14 @@ jobs:
with:
report_dir: blob-report
job_name: docker-${{ matrix.docker_tag }}-${{ matrix.docker_arch }}

- name: Copy parquet report from container
if: ${{ !cancelled() }}
run: docker cp "docker-tests:/home/pwuser/playwright/test-results/test-results.parquet" "./test-results.parquet" || true

- name: Upload parquet report
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-parquet-report
with:
report_file: test-results.parquet
job_name: docker-${{ matrix.docker_tag }}-${{ matrix.docker_arch }}
5 changes: 5 additions & 0 deletions .github/workflows/tests_extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,8 @@ jobs:
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: npm run test-extension
- name: Upload parquet report
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-parquet-report
with:
job_name: extension-${{ matrix.os }}
6 changes: 6 additions & 0 deletions .github/workflows/tests_webview_simulator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,9 @@ jobs:
path: |
${{ github.workspace }}/test-results/**
if-no-files-found: ignore

- name: Upload parquet report
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-parquet-report
with:
job_name: webview-simulator-${{ matrix.shard }}
143 changes: 143 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@babel/plugin-transform-private-property-in-object": "7.29.7",
"@babel/plugin-transform-react-jsx": "7.29.7",
"@babel/preset-typescript": "7.29.7",
"@duckdb/node-api": "1.5.4-r.1",
"@eslint/compat": "2.1.0",
"@eslint/eslintrc": "3.3.5",
"@eslint/js": "9.39.4",
Expand Down
83 changes: 24 additions & 59 deletions packages/injected/src/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ export class InjectedScript {
return { ariaSnapshot, refs: tree.refs };
}

ariaSnapshotForExpectFailure(element: Element, options: AriaTreeOptions): string {
// Bypass _lastAriaSnapshotForQuery — that cache is reserved for explicit
// ariaSnapshot() calls used by the aria-ref selector engine.
return renderAriaTree(generateAriaTree(element, options), options).text;
}

getAllElementsMatchingExpectAriaTemplate(document: Document, template: AriaTemplateNode): Element[] {
return getAllElementsMatchingExpectAriaTemplate(document.documentElement, template);
}
Expand Down Expand Up @@ -1437,85 +1443,44 @@ export class InjectedScript {
this.onGlobalListenersRemoved.add(addHitTargetInterceptorListeners);
}

async expect(element: Element | undefined, options: FrameExpectParams, elements: Element[]): Promise<{ matches: boolean, received?: ExpectReceived, missingReceived?: boolean }> {
const core = await this._expectCore(element, options, elements);
async expect(element: Element, options: FrameExpectParams, elements: Element[]): Promise<{ matches: boolean, received?: ExpectReceived }> {
const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array');
const core = isArray ? this.expectArray(elements, options) : await this.expectSingleElement(element, options);
const ariaSnapshot = core.matches !== options.isNot ? undefined : this._ariaSnapshotForExpect(element, options);
if (core.received === undefined && ariaSnapshot === undefined)
return { matches: core.matches, missingReceived: core.missingReceived };
return { matches: core.matches, received: { value: core.received, ariaSnapshot }, missingReceived: core.missingReceived };
return { matches: core.matches };
return { matches: core.matches, received: { value: core.received, ariaSnapshot } };
}

private _ariaSnapshotForExpect(element: Element | undefined, options: FrameExpectParams): string | undefined {
private _ariaSnapshotForExpect(element: Element, options: FrameExpectParams): string | undefined {
const expression = options.expression;
if (expression === 'to.have.count' || expression.endsWith('.array'))
return undefined;
if (expression === 'to.match.aria')
if (expression === 'to.have.count' || expression.endsWith('.array') || expression === 'to.match.aria')
return undefined;
if (element && isElementVisible(element)) {
if (isElementVisible(element) && expression !== 'to.have.title' && expression !== 'to.have.url') {
// Element-scoped snapshot. Containment matchers want the full subtree;
// property matchers only need the element's own line.
const isContainment = expression === 'to.have.text';
return this._renderAriaSnapshot(element, { mode: 'default', depth: isContainment ? undefined : 1 });
return this.ariaSnapshotForExpectFailure(element, { mode: 'default', depth: isContainment ? undefined : 1 });
}
// Element missing or hidden — fall back to a full-page snapshot for context.
if (!this.document.body)
return undefined;
return this._renderAriaSnapshot(this.document.body, { mode: 'default' });
return this.ariaSnapshotForExpectFailure(this.document.body, { mode: 'default' });
}

private _renderAriaSnapshot(element: Element, options: AriaTreeOptions): string {
// Bypass _lastAriaSnapshotForQuery — that cache is reserved for explicit
// ariaSnapshot() calls used by the aria-ref selector engine.
return renderAriaTree(generateAriaTree(element, options), options).text;
}
private async expectSingleElement(element: Element, options: FrameExpectParams): Promise<{ matches: boolean, received?: any }> {
const expression = options.expression;

private async _expectCore(element: Element | undefined, options: FrameExpectParams, elements: Element[]): Promise<{ matches: boolean, received?: any, missingReceived?: boolean }> {
const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array');
if (isArray)
return this.expectArray(elements, options);
if (!element) {
// expect(locator).toBeHidden() passes when there is no element.
if (!options.isNot && options.expression === 'to.be.hidden')
return { matches: true };
// expect(locator).not.toBeVisible() passes when there is no element.
if (options.isNot && options.expression === 'to.be.visible')
return { matches: false };
// expect(locator).toBeAttached({ attached: false }) passes when there is no element.
if (!options.isNot && options.expression === 'to.be.detached')
return { matches: true };
// expect(locator).not.toBeAttached() passes when there is no element.
if (options.isNot && options.expression === 'to.be.attached')
return { matches: false };
// expect(locator).not.toBeInViewport() passes when there is no element.
if (options.isNot && options.expression === 'to.be.in.viewport')
return { matches: false };
if (options.expression === 'to.have.title' && options?.expectedText?.[0]) {
const matcher = new ExpectedTextMatcher(options.expectedText[0]);
{
// Page-level values. The element (:root) is only used to reach the document.
if (expression === 'to.have.title') {
const received = this.document.title;
return { received, matches: matcher.matches(received) };
return { received, matches: new ExpectedTextMatcher(options.expectedText![0]).matches(received) };
}
if (options.expression === 'to.have.url' && options?.expectedText?.[0]) {
const matcher = new ExpectedTextMatcher(options.expectedText[0]);
if (expression === 'to.have.url') {
const received = this.document.location.href;
return { received, matches: matcher.matches(received) };
}
if (options.expression === 'to.match.aria' && !options.selector) {
if (!this.document.body)
return { matches: options.isNot, missingReceived: true };
const result = matchesExpectAriaTemplate(this.document.body, options.expectedValue);
return {
received: result.received,
matches: !!result.matches.length,
};
return { received, matches: new ExpectedTextMatcher(options.expectedText![0]).matches(received) };
}
// When none of the above applies, expect does not match.
return { matches: options.isNot, missingReceived: true };
}
return await this.expectSingleElement(element, options);
}

private async expectSingleElement(element: Element, options: FrameExpectParams): Promise<{ matches: boolean, received?: any }> {
const expression = options.expression;

{
// Element state / boolean values.
Expand Down
Loading
Loading