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
135 changes: 135 additions & 0 deletions .claude/skills/playwright-test-results/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
name: playwright-test-results
description: Query Playwright CI test results from the aggregated DuckDB database. Answers questions about flaky tests, failure rates, slow tests, and per-run/SHA/PR results without hunting through GitHub artifacts.
user_invocable: true
---

# Playwright Test Results (DuckDB)

A single DuckDB file holds recent Playwright CI test results, so you can answer
questions about failures, flakiness, and slow tests with plain SQL. It is
refreshed every few hours.

## Get the database

Download the latest snapshot:

```bash
npm ci # first time only, from the repo root
GITHUB_TOKEN=$(gh auth token) node utils/test-results-db/cli.ts download
```

The snapshot may be missing the newest runs. To top it up locally, run `update`:

```bash
GITHUB_TOKEN=$(gh auth token) node utils/test-results-db/cli.ts update --lookback-days 3
```

Query it with the `duckdb` CLI (or any DuckDB client):

```bash
duckdb utils/test-results-db/test-results.duckdb "SELECT count(*) FROM test_results"
```

## Schema

Single table `test_results`, one row per test result (**one row per retry**).
The columns are inferred from the parquet the reporter emits
(`tests/config/parquetReporter.ts`), plus two trailing columns this CLI adds:

| Column | Meaning |
| --- | --- |
| `run_id`, `run_attempt` | GitHub Actions run identity |
| `run_started_at` | when the run started |
| `workflow_name` | e.g. `tests 1` / `tests 2` / `tests others` / `MCP` |
| `event` | `push` / `pull_request` |
| `head_sha`, `head_branch`, `pr_number` | what was tested |
| `bot_name` | e.g. `chromium-ubuntu-22.04-node20`, `webkit-macos-15-large` — the CI bot. **OS and arch are encoded here**; there is no separate os column. |
| `project_name` | CI project = browser + suite, e.g. `chromium-page`, `webkit-library`, `playwright-test` |
| `test_title` | title path within the file, joined by ` › ` (`describe › test`) |
| `file`, `line`, `column_number` | source location (file is relative to repo root) |
| `expected_status` | `passed` / `skipped` / ... |
| `status` | actual result: `passed` / `failed` / `timedOut` / `skipped` / `interrupted` |
| `retry` | 0 = first attempt |
| `result_started_at` | when this attempt started |
| `duration_ms` | result duration |
| `error_message` | all errors joined, ANSI-stripped (NULL when none) |
| `tags` | **list** of strings, e.g. `['@slow', '@flaky']` (use list functions / `list_contains`) |
| `annotations` | list of `{type, description}` structs, e.g. `[{'type': 'skip', 'description': 'flaky on CI'}]` (empty list when none) |
| `artifact_id` | the GitHub artifact this row came from (dedupe key) |
| `ingested_at` | debug only — when this row was imported |

Notes:
- **A test is identified by `(project_name, file, test_title)`** — group on that
tuple. (Playwright's `test_id` hash is deliberately not stored; those three
columns are its pre-image.)
- **Flakiness is derived**, not stored. The signal that matters most is
**cross-run**: a test whose *final* verdict (after retries) flips between
runs — green in some, red in others. A separate **within-run** flake is a
test a retry rescued inside a single run (`failed`→`passed`).
- **Real failures vs intentional ones:** filter `expected_status = 'passed'`.
Tests marked `test.fail()` record `status='failed'` *with*
`expected_status='failed'` and would otherwise dominate any "most failing" list.
- The db is size-capped by **run count**: the oldest whole runs are evicted over
time, so it holds a recent window, not full history.

## Example queries

Group tests by `(project_name, file, test_title)` and (for failure/flakiness)
scope to `expected_status = 'passed'` so intentional `test.fail()` tests don't
skew the results.

**Flaky across runs** — the test's final verdict flips between runs (this is
what makes a red CI run ambiguous). `least(failed_runs, passed_runs)` ranks
genuinely bimodal tests above both always-broken and one-off failures:

```sql
WITH per_run AS (
SELECT project_name, file, test_title, run_id, run_attempt,
arg_max(status, retry) AS final_status,
any_value(expected_status) AS expected
FROM test_results
GROUP BY project_name, file, test_title, run_id, run_attempt)
SELECT project_name, test_title,
count(*) AS runs,
count(*) FILTER (WHERE final_status IN ('failed','timedOut')) AS failed_runs,
count(*) FILTER (WHERE final_status = 'passed') AS passed_runs,
round(100.0 * count(*) FILTER (WHERE final_status IN ('failed','timedOut'))
/ count(*), 1) AS fail_pct
FROM per_run
WHERE expected = 'passed'
GROUP BY project_name, test_title
HAVING failed_runs > 0 AND passed_runs > 0 AND runs >= 10
ORDER BY least(failed_runs, passed_runs) DESC, failed_runs DESC
LIMIT 20;
```

**Filter by tag** (`tags` is a list, not a string):

```sql
SELECT project_name, test_title, count(*) AS runs
FROM test_results
WHERE list_contains(tags, '@slow')
GROUP BY project_name, test_title
ORDER BY runs DESC
LIMIT 20;
```

## Fetching the full detail

The db stores per-result summaries. For the full step tree / attachments / stdio,
fetch the original blob report for that run, if the run uploaded one. A row
identifies it by `run_id` + `bot_name`: the run's blob artifact is named
`blob-report-<bot_name>`.

```bash
# List the run's blob artifacts and find the one for this bot_name:
gh api /repos/microsoft/playwright/actions/runs/<run_id>/artifacts \
--jq '.artifacts[] | select(.name | startswith("blob-report")) | {id, name}'

# Download it (name == "blob-report-<bot_name>"):
gh api /repos/microsoft/playwright/actions/artifacts/<artifact_id>/zip > blob.zip
```

Blob and parquet artifacts have a 7-day retention, so this works only for recent
runs; the db itself retains summaries longer (until run-count eviction).
46 changes: 46 additions & 0 deletions .github/workflows/update_test_results_db.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: "Update test results DB"
on:
workflow_dispatch:
schedule:
- cron: "0 */3 * * *"

concurrency:
group: test-results-db
cancel-in-progress: false

jobs:
update:
name: Update DuckDB
runs-on: ubuntu-22.04
timeout-minutes: 60
if: github.repository == 'microsoft/playwright'
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
- run: npm ci
- name: Download current database
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node utils/test-results-db/cli.ts download
- name: Ingest new test results
id: ingest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
run: node utils/test-results-db/cli.ts update --lookback-days 7 --concurrency 32
- name: Truncate to run cap
if: steps.ingest.outputs.imported != '0'
run: node utils/test-results-db/cli.ts truncate --max-runs 2000
- name: Upload database
if: steps.ingest.outputs.imported != '0'
uses: actions/upload-artifact@v7
with:
name: test-results-db
path: utils/test-results-db/test-results.duckdb
retention-days: 7
overwrite: true
3 changes: 2 additions & 1 deletion packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ export class Response {
if (this._includeSnapshot !== 'none' || tabHeaders.some(header => header.changed)) {
if (tabHeaders.length !== 1)
addSection('Open tabs', renderTabsMarkdown(tabHeaders));
addSection('Page', renderTabMarkdown(tabHeaders.find(h => h.current) ?? tabHeaders[0]));
if (tabHeaders.length)
addSection('Page', renderTabMarkdown(tabHeaders.find(h => h.current) ?? tabHeaders[0]));
}

// Handle modal states.
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/isomorphic/testServerConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import * as events from './events';

import type { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface';
import type { TestServerInterface, TestServerInterfaceEvents } from './testServerInterface';
import type * as reporterTypes from '../../types/testReporter';

// -- Reuse boundary -- Everything below this line is reused in the vscode extension.
Expand Down
14 changes: 0 additions & 14 deletions packages/playwright/src/transform/compilationCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import path from 'path';

import sourceMapSupport from 'source-map-support';
import { calculateSha1 } from '@utils/crypto';
import { isUnderTest } from '@utils/debug';

import { isWorkerProcess } from '../globals';
import { packageRoot } from '../package';
Expand Down Expand Up @@ -69,17 +68,13 @@ const fileDependencies = new Map<string, Set<string>>();
// Dependencies resolved by the external bundler.
const externalDependencies = new Map<string, Set<string>>();

const devSourceInfix = path.sep + 'playwright' + path.sep + 'packages' + path.sep;

export function installSourceMapSupport() {
Error.stackTraceLimit = 200;

sourceMapSupport.install({
environment: 'node',
handleUncaughtExceptions: false,
retrieveSourceMap(source) {
if (!process.env.PWDEBUGIMPL && isUnderTest() && source.includes(devSourceInfix))
return { map: identitySourceMap(source), url: source };
if (!sourceMaps.has(source))
return null;
const sourceMapPath = sourceMaps.get(source)!;
Expand All @@ -95,15 +90,6 @@ export function installSourceMapSupport() {
});
}

function identitySourceMap(source: string) {
const lineCount = fs.readFileSync(source, 'utf8').split('\n').length;
return {
version: 3,
sources: [source],
mappings: lineCount ? 'AAAA' + ';AACA'.repeat(lineCount - 1) : '',
};
}

function _innerAddToCompilationCacheAndSerialize(filename: string, entry: MemoryCache) {
sourceMaps.set(entry.moduleUrl || filename, entry.sourceMapPath);
memoryCache.set(filename, entry);
Expand Down
5 changes: 5 additions & 0 deletions packages/utils/stackTrace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import path from 'path';

import { getAsBooleanFromENV } from './env';
import { isUnderTest } from './debug';

export type RawStack = string[];

Expand Down Expand Up @@ -173,6 +174,10 @@ export function filterStackFile(file: string) {
return false;
if (_boxedStackPrefixes.some(prefix => file.startsWith(prefix)))
return false;
if (isUnderTest() && file.match(/[/\\]packages[/\\](playwright|playwright-core|utils|isomorphic|injected)[/\\]/))
return false;
if (isUnderTest() && file.match(/[/\\]playwright[^/\\]*[/\\]node_modules[/\\]/))
return false;
return true;
}

Expand Down
34 changes: 15 additions & 19 deletions tests/library/page-clock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,66 +399,62 @@ it.describe('setFixedTime', () => {
it.describe('while running', () => {
it('should progress time', async ({ page }) => {
await page.clock.install({ time: 0 });
const startRealTime = Date.now();
await page.goto('data:text/html,');
await page.waitForTimeout(1000);
const now = await page.evaluate(() => Date.now());
const realElapsed = Date.now() - startRealTime;
expect(now).toBeGreaterThanOrEqual(1000);
expect(now).toBeLessThanOrEqual(2000);
expect(now).toBeLessThanOrEqual(realElapsed + 1000);
});

it('should runFor', async ({ page }) => {
await page.clock.install({ time: 0 });
const startRealTime = Date.now();
await page.goto('data:text/html,');
await page.clock.runFor(10000);
const now = await page.evaluate(() => Date.now());
const realElapsed = Date.now() - startRealTime;
expect(now).toBeGreaterThanOrEqual(10000);
expect(now).toBeLessThanOrEqual(11000);
expect(now).toBeLessThanOrEqual(10000 + realElapsed + 1000);
});

it('should fastForward', async ({ page }) => {
await page.clock.install({ time: 0 });
const startRealTime = Date.now();
await page.goto('data:text/html,');
await page.clock.fastForward(10000);
const now = await page.evaluate(() => Date.now());
const realElapsed = Date.now() - startRealTime;
expect(now).toBeGreaterThanOrEqual(10000);
expect(now).toBeLessThanOrEqual(11000);
});

it('should fastForwardTo', async ({ page }) => {
await page.clock.install({ time: 0 });
await page.goto('data:text/html,');
await page.clock.fastForward(10000);
const now = await page.evaluate(() => Date.now());
expect(now).toBeGreaterThanOrEqual(10000);
expect(now).toBeLessThanOrEqual(11000);
expect(now).toBeLessThanOrEqual(10000 + realElapsed + 1000);
});

it('should pause', async ({ page }) => {
await page.clock.install({ time: 0 });
await page.goto('data:text/html,');
await page.clock.pauseAt(1000);
await page.clock.pauseAt(60000);
// Internally wait to make sure the clock is paused and not running.
await page.waitForTimeout(1111);
const now = await page.evaluate(() => Date.now());
expect(now).toBeGreaterThanOrEqual(0);
expect(now).toBeLessThanOrEqual(1000);
expect(now).toBe(60000);
});

it('should pause and fastForward', async ({ page }) => {
await page.clock.install({ time: 0 });
await page.goto('data:text/html,');
await page.clock.pauseAt(1000);
await page.clock.pauseAt(60000);
await page.clock.fastForward(1000);
const now = await page.evaluate(() => Date.now());
expect(now).toBe(2000);
expect(now).toBe(61000);
});

it('should set system time on pause', async ({ page }) => {
await page.clock.install({ time: 0 });
await page.goto('data:text/html,');
await page.clock.pauseAt(1000);
await page.clock.pauseAt(60000);
const now = await page.evaluate(() => Date.now());
expect(now).toBe(1000);
expect(now).toBe(60000);
});
});

Expand Down
7 changes: 7 additions & 0 deletions tests/mcp/cli-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,10 @@ test('run-code', async ({ cli, server }) => {
const { output } = await cli('run-code', '() => page.title()');
expect(output).toContain('"Title"');
});

test('goto chrome:// page that closes the tab does not crash the response', async ({ cli, server, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium' && mcpBrowser !== 'chrome', 'chrome:// pages are chromium-specific');
await cli('open', server.HELLO_WORLD);
const { output } = await cli('goto', 'chrome://extensions/');
expect(output).toContain('No open tabs. Navigate to a URL to create one.');
});
2 changes: 1 addition & 1 deletion tests/playwright-test/basic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ test('should succeed', async ({ runInlineTest }) => {
test('should report suite errors', async ({ runInlineTest }) => {
const { exitCode, failed, output } = await runInlineTest({
'suite-error.spec.ts': `
if (new Error().stack.includes('workerProcess'))
if (process.env.TEST_WORKER_INDEX)
throw new Error('Suite error');

import { test, expect } from '@playwright/test';
Expand Down
Loading
Loading