@empiricalrun/playwright-utils 0.58.2 → 0.58.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +12 -2
- package/dist/generated/api-worker-openapi.d.ts +98 -0
- package/dist/generated/api-worker-openapi.d.ts.map +1 -1
- package/dist/reporter/empirical-reporter.d.ts.map +1 -1
- package/dist/reporter/empirical-reporter.js +9 -0
- package/dist/reporter/harness.d.ts +36 -0
- package/dist/reporter/harness.d.ts.map +1 -1
- package/dist/reporter/harness.js +81 -2
- package/dist/reporter/ibr-utils.d.ts +6 -0
- package/dist/reporter/ibr-utils.d.ts.map +1 -1
- package/dist/reporter/ibr-utils.js +15 -1
- package/dist/reporter/incremental-blob-reporter.d.ts +6 -1
- package/dist/reporter/incremental-blob-reporter.d.ts.map +1 -1
- package/dist/reporter/incremental-blob-reporter.js +30 -6
- package/dist/reporter/lifecycle-events.d.ts +5 -3
- package/dist/reporter/lifecycle-events.d.ts.map +1 -1
- package/dist/reporter/lifecycle-events.js +54 -3
- package/dist/reporter/parity-utils.d.ts +63 -0
- package/dist/reporter/parity-utils.d.ts.map +1 -0
- package/dist/reporter/parity-utils.js +109 -0
- package/package.json +2 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/upstream-oracle/README.md +48 -0
- package/upstream-oracle/ibr-adapter.js +72 -0
- package/upstream-oracle/intermediate-mode-ibr.patch +16 -0
- package/upstream-oracle/run.mjs +395 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeJsonReport = normalizeJsonReport;
|
|
4
|
+
exports.stepSignatures = stepSignatures;
|
|
5
|
+
exports.missingEventMethods = missingEventMethods;
|
|
6
|
+
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
7
|
+
function normalizeText(text, cwd) {
|
|
8
|
+
return text.replace(ANSI_RE, "").split(cwd).join("<cwd>").trimEnd();
|
|
9
|
+
}
|
|
10
|
+
function normalizeStdio(entries, cwd) {
|
|
11
|
+
return (entries ?? []).map((entry) => typeof entry === "string"
|
|
12
|
+
? normalizeText(entry, cwd)
|
|
13
|
+
: entry.text !== undefined
|
|
14
|
+
? normalizeText(entry.text, cwd)
|
|
15
|
+
: `<buffer:${entry.buffer?.length ?? 0}>`);
|
|
16
|
+
}
|
|
17
|
+
function normalizeResult(result, opts) {
|
|
18
|
+
return {
|
|
19
|
+
retry: result.retry,
|
|
20
|
+
status: result.status,
|
|
21
|
+
errorMessages: (result.errors ?? []).map((e) => normalizeText(e.message ?? "", opts.cwd)),
|
|
22
|
+
stdout: normalizeStdio(result.stdout, opts.cwd),
|
|
23
|
+
stderr: normalizeStdio(result.stderr, opts.cwd),
|
|
24
|
+
attachments: (result.attachments ?? []).map((a) => ({
|
|
25
|
+
name: a.name,
|
|
26
|
+
contentType: a.contentType,
|
|
27
|
+
hasContent: opts.compareAttachmentContent
|
|
28
|
+
? Boolean(a.path || a.body)
|
|
29
|
+
: true,
|
|
30
|
+
})),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function normalizeSuite(suite, opts) {
|
|
34
|
+
return {
|
|
35
|
+
title: suite.title,
|
|
36
|
+
file: suite.file,
|
|
37
|
+
specs: (suite.specs ?? [])
|
|
38
|
+
.map((spec) => ({
|
|
39
|
+
title: spec.title,
|
|
40
|
+
file: spec.file,
|
|
41
|
+
ok: spec.ok,
|
|
42
|
+
tests: (spec.tests ?? []).map((test) => ({
|
|
43
|
+
projectName: test.projectName,
|
|
44
|
+
expectedStatus: test.expectedStatus,
|
|
45
|
+
status: test.status,
|
|
46
|
+
annotations: test.annotations ?? [],
|
|
47
|
+
results: (test.results ?? [])
|
|
48
|
+
.map((r) => normalizeResult(r, opts))
|
|
49
|
+
.sort((a, b) => a.retry - b.retry),
|
|
50
|
+
})),
|
|
51
|
+
}))
|
|
52
|
+
.sort((a, b) => a.title.localeCompare(b.title)),
|
|
53
|
+
suites: (suite.suites ?? [])
|
|
54
|
+
.map((s) => normalizeSuite(s, opts))
|
|
55
|
+
.sort((a, b) => a.title.localeCompare(b.title)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function normalizeJsonReport(report, opts) {
|
|
59
|
+
const typed = report;
|
|
60
|
+
const stats = { ...(typed.stats ?? {}) };
|
|
61
|
+
delete stats.startTime;
|
|
62
|
+
delete stats.duration;
|
|
63
|
+
return {
|
|
64
|
+
suites: (typed.suites ?? [])
|
|
65
|
+
.map((s) => normalizeSuite(s, opts))
|
|
66
|
+
.sort((a, b) => (a.file + a.title).localeCompare(b.file + b.title)),
|
|
67
|
+
errorMessages: (typed.errors ?? []).map((e) => normalizeText(e.message ?? "", opts.cwd)),
|
|
68
|
+
stats,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Per-result step signatures from a raw blob event stream, keyed by
|
|
73
|
+
* `testId:retry` (stable across both reporters — they see the same run).
|
|
74
|
+
* The merged JSON report drops steps, so step parity is checked here.
|
|
75
|
+
*/
|
|
76
|
+
function stepSignatures(events) {
|
|
77
|
+
const resultKey = new Map(); // resultId -> testId:retry
|
|
78
|
+
for (const event of events) {
|
|
79
|
+
if (event.method !== "onTestBegin") {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const params = event.params;
|
|
83
|
+
resultKey.set(params.result.id, `${params.testId}:${params.result.retry}`);
|
|
84
|
+
}
|
|
85
|
+
const signatures = new Map();
|
|
86
|
+
for (const event of events) {
|
|
87
|
+
if (event.method !== "onStepBegin") {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const params = event.params;
|
|
91
|
+
const key = resultKey.get(params.resultId);
|
|
92
|
+
if (!key) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const list = signatures.get(key) ?? [];
|
|
96
|
+
list.push(`${params.step.category}:${params.step.title}`);
|
|
97
|
+
signatures.set(key, list);
|
|
98
|
+
}
|
|
99
|
+
return signatures;
|
|
100
|
+
}
|
|
101
|
+
/** Event method names present in `reference` but absent from `candidate`. */
|
|
102
|
+
function missingEventMethods(reference, candidate) {
|
|
103
|
+
const candidateMethods = new Set(candidate.map((e) => e.method));
|
|
104
|
+
return [
|
|
105
|
+
...new Set(reference
|
|
106
|
+
.map((e) => e.method)
|
|
107
|
+
.filter((method) => !candidateMethods.has(method))),
|
|
108
|
+
].sort();
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@empiricalrun/playwright-utils",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.4",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org/",
|
|
6
6
|
"access": "public"
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"openapi-typescript": "7.6.1",
|
|
42
42
|
"playwright-core": "1.62.1",
|
|
43
43
|
"serve-handler": "^6.1.6",
|
|
44
|
-
"@empiricalrun/shared-types": "0.15.
|
|
44
|
+
"@empiricalrun/shared-types": "0.15.4"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@babel/code-frame": "^7.24.7",
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"7.0.0-dev.20260623.1","root":["./src/api-worker-client.ts","./src/email.ts","./src/inbox-client.ts","./src/index.ts","./src/kv.ts","./src/logger.ts","./src/mailosaur-client.ts","./src/playwright-extensions.ts","./src/telemetry.ts","./src/webhook.ts","./src/auth/google.ts","./src/auth/index.ts","./src/auth/types.ts","./src/captcha/index.ts","./src/config/index.ts","./src/config/proxy.ts","./src/config/devices/types.ts","./src/generated/api-worker-openapi.ts","./src/overlay-tests/cache.spec.ts","./src/overlay-tests/click.spec.ts","./src/overlay-tests/fixtures.ts","./src/overlay-tests/patch.spec.ts","./src/overlay-tests/visual.spec.ts","./src/reporter/attachment-cleanup-test-reporter.ts","./src/reporter/attachment-cleanup.ts","./src/reporter/blob-utils.ts","./src/reporter/empirical-reporter.ts","./src/reporter/failing-line.ts","./src/reporter/harness.ts","./src/reporter/ibr-utils.ts","./src/reporter/incremental-blob-reporter.ts","./src/reporter/lifecycle-events.ts","./src/reporter/local-test.ts","./src/reporter/reporter-state.ts","./src/reporter/test-case-sync-reporter.ts","./src/reporter/test-case-sync.ts","./src/reporter/uploader.ts","./src/reporter/util.ts","./src/test/constants.ts","./src/test/coverage.ts","./src/test/index.ts","./src/test/types.ts","./src/test/video-labels.ts","./src/test/expect/index.ts","./src/test/expect/types.ts","./src/test/expect/visual.ts","./src/test/expect/webhook.ts","./src/test/scripts/agent-capabilities.ts","./src/test/scripts/index.ts","./src/test/scripts/locator-highlights.ts","./src/test/scripts/locator-vision.ts","./src/test/scripts/mouse-pointer.ts","./src/test/scripts/types.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/cache.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/index.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/prompt.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/types.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/utils.ts","./src/test/scripts/pw-locator-patch/highlight/click.ts","./src/test/scripts/pw-locator-patch/highlight/expect.ts","./src/test/scripts/pw-locator-patch/highlight/hover.ts","./src/test/scripts/pw-locator-patch/highlight/inner-text.ts","./src/test/scripts/pw-locator-patch/highlight/input-value.ts","./src/test/scripts/pw-locator-patch/highlight/is-checked.ts","./src/test/scripts/pw-locator-patch/highlight/is-disabled.ts","./src/test/scripts/pw-locator-patch/highlight/is-editable.ts","./src/test/scripts/pw-locator-patch/highlight/text-content.ts","./src/test/scripts/pw-locator-patch/utils/index.ts","./src/test/scripts/pw-locator-patch/vision/query.ts"]}
|
|
1
|
+
{"version":"7.0.0-dev.20260623.1","root":["./src/api-worker-client.ts","./src/email.ts","./src/inbox-client.ts","./src/index.ts","./src/kv.ts","./src/logger.ts","./src/mailosaur-client.ts","./src/playwright-extensions.ts","./src/telemetry.ts","./src/webhook.ts","./src/auth/google.ts","./src/auth/index.ts","./src/auth/types.ts","./src/captcha/index.ts","./src/config/index.ts","./src/config/proxy.ts","./src/config/devices/types.ts","./src/generated/api-worker-openapi.ts","./src/overlay-tests/cache.spec.ts","./src/overlay-tests/click.spec.ts","./src/overlay-tests/fixtures.ts","./src/overlay-tests/patch.spec.ts","./src/overlay-tests/visual.spec.ts","./src/reporter/attachment-cleanup-test-reporter.ts","./src/reporter/attachment-cleanup.ts","./src/reporter/blob-utils.ts","./src/reporter/empirical-reporter.ts","./src/reporter/failing-line.ts","./src/reporter/harness.ts","./src/reporter/ibr-utils.ts","./src/reporter/incremental-blob-reporter.ts","./src/reporter/lifecycle-events.ts","./src/reporter/local-test.ts","./src/reporter/parity-utils.ts","./src/reporter/reporter-state.ts","./src/reporter/test-case-sync-reporter.ts","./src/reporter/test-case-sync.ts","./src/reporter/uploader.ts","./src/reporter/util.ts","./src/test/constants.ts","./src/test/coverage.ts","./src/test/index.ts","./src/test/types.ts","./src/test/video-labels.ts","./src/test/expect/index.ts","./src/test/expect/types.ts","./src/test/expect/visual.ts","./src/test/expect/webhook.ts","./src/test/scripts/agent-capabilities.ts","./src/test/scripts/index.ts","./src/test/scripts/locator-highlights.ts","./src/test/scripts/locator-vision.ts","./src/test/scripts/mouse-pointer.ts","./src/test/scripts/types.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/cache.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/index.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/prompt.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/types.ts","./src/test/scripts/pw-locator-patch/dismiss-overlays/utils.ts","./src/test/scripts/pw-locator-patch/highlight/click.ts","./src/test/scripts/pw-locator-patch/highlight/expect.ts","./src/test/scripts/pw-locator-patch/highlight/hover.ts","./src/test/scripts/pw-locator-patch/highlight/inner-text.ts","./src/test/scripts/pw-locator-patch/highlight/input-value.ts","./src/test/scripts/pw-locator-patch/highlight/is-checked.ts","./src/test/scripts/pw-locator-patch/highlight/is-disabled.ts","./src/test/scripts/pw-locator-patch/highlight/is-editable.ts","./src/test/scripts/pw-locator-patch/highlight/text-content.ts","./src/test/scripts/pw-locator-patch/utils/index.ts","./src/test/scripts/pw-locator-patch/vision/query.ts"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Upstream Playwright oracle for the IncrementalBlobReporter
|
|
2
|
+
|
|
3
|
+
Runs upstream Playwright's own runner test suite (`tests/playwright-test`)
|
|
4
|
+
through our IncrementalBlobReporter (IBR) and diffs failures against the
|
|
5
|
+
built-in blob reporter as baseline. Their
|
|
6
|
+
`PWTEST_INTERMEDIATE_BLOB_REPORT=1` harness mode reroutes every inline
|
|
7
|
+
scenario through `<reporter> -> merge-reports -> json` before applying the
|
|
8
|
+
normal assertions — Playwright's own completeness check for the blob format.
|
|
9
|
+
Swapping IBR in (via `ibr-adapter.js` + the one-line
|
|
10
|
+
`intermediate-mode-ibr.patch`) turns it into a parity oracle for us.
|
|
11
|
+
|
|
12
|
+
This backs the plan to drop the built-in blob reporter from sharded runs:
|
|
13
|
+
before trusting IBR as the only report, its zip must merge identically on
|
|
14
|
+
scenarios we did not think to write ourselves. The fast, per-PR half of that
|
|
15
|
+
story is `src/reporter/blob-ibr-parity.test.ts`; this oracle is the deep
|
|
16
|
+
audit, run by `.github/workflows/ibr-blob-oracle.yaml` (and re-run on every
|
|
17
|
+
Playwright version-pin bump).
|
|
18
|
+
|
|
19
|
+
## Files
|
|
20
|
+
|
|
21
|
+
- `run.mjs` — orchestrator: applies the patch, runs the curated specs in both
|
|
22
|
+
modes, fails on any IBR-only failure.
|
|
23
|
+
- `ibr-adapter.js` — bridges IBR's env/paths contract to the blob reporter's
|
|
24
|
+
(config.shard → SHARD_INDEX env, zip relocation to `blob-report/`).
|
|
25
|
+
- `intermediate-mode-ibr.patch` — one-line upstream fixture change making the
|
|
26
|
+
intermediate reporter selectable via `PWTEST_IBR_ADAPTER`.
|
|
27
|
+
|
|
28
|
+
The job is EXPECTED to stay red until IBR reaches parity: today ~55 upstream
|
|
29
|
+
tests fail only through IBR, all tracing to the known gaps (`onStdIO`,
|
|
30
|
+
`onError`, result-level annotations, attachment bodies — encoded as `it.fails`
|
|
31
|
+
in `src/reporter/blob-ibr-parity.test.ts`). The failure count shrinking to
|
|
32
|
+
zero is the signal that the built-in blob reporter is safe to drop.
|
|
33
|
+
|
|
34
|
+
## Running locally
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# One-time: clone + bootstrap upstream at the pinned tag (~10 min)
|
|
38
|
+
git clone --depth 1 --branch v1.62.1 https://github.com/microsoft/playwright.git ~/playwright-upstream
|
|
39
|
+
cd ~/playwright-upstream && npm ci && npm run build
|
|
40
|
+
|
|
41
|
+
# Build IBR, then run the oracle
|
|
42
|
+
cd <monorepo>/packages/playwright-utils && pnpm build
|
|
43
|
+
node upstream-oracle/run.mjs --pw-repo ~/playwright-upstream
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The pin lives in the workflow (`PLAYWRIGHT_UPSTREAM_TAG`) and must move with
|
|
47
|
+
the `@playwright/test` dev dependency; IBR mirrors blob format version 2
|
|
48
|
+
(`BLOB_REPORT_VERSION` in `src/reporter/lifecycle-events.ts`).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter that lets the IncrementalBlobReporter (IBR) stand in for
|
|
3
|
+
* Playwright's built-in blob reporter inside the upstream Playwright repo's
|
|
4
|
+
* test harness (tests/playwright-test).
|
|
5
|
+
*
|
|
6
|
+
* Used with PWTEST_INTERMEDIATE_BLOB_REPORT=1 and the fixture patch in
|
|
7
|
+
* intermediate-mode-ibr.patch: every inline scenario in upstream's runner
|
|
8
|
+
* suite then executes through IBR -> merge-reports instead of blob ->
|
|
9
|
+
* merge-reports, turning Playwright's own tests into a completeness oracle
|
|
10
|
+
* for the IBR blob format. See run.mjs for orchestration.
|
|
11
|
+
*
|
|
12
|
+
* A subclass rather than a wrapper so every event handler — including ones
|
|
13
|
+
* IBR gains later — is inherited automatically instead of hand-forwarded
|
|
14
|
+
* (a wrapper once silently swallowed onStdOut/onError). The only remaining
|
|
15
|
+
* bridge is output location: the harness merges <packageRoot>/blob-report/
|
|
16
|
+
* report-N.zip, IBR writes blob-report-incremental/incremental-report-N.zip,
|
|
17
|
+
* so the zip is copied over after onEnd. Sharding needs no bridge: IBR falls
|
|
18
|
+
* back to config.shard when SHARD_INDEX/TOTAL_SHARDS are not set.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
|
|
24
|
+
const IBR_DIST = process.env.PWTEST_IBR_DIST;
|
|
25
|
+
if (!IBR_DIST) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"PWTEST_IBR_DIST must point at a built incremental-blob-reporter.js " +
|
|
28
|
+
"(e.g. <monorepo>/packages/playwright-utils/dist/reporter/incremental-blob-reporter.js)",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Keep IBR off its local-testing path (attachment embedding + auto-merge)
|
|
33
|
+
// and away from real uploads. Must happen before construction: IBR reads
|
|
34
|
+
// env when instantiated.
|
|
35
|
+
delete process.env.LOCAL_TEST;
|
|
36
|
+
delete process.env.R2_ACCOUNT_ID;
|
|
37
|
+
delete process.env.S3_UPLOAD_BUCKET;
|
|
38
|
+
delete process.env.SHARD_INDEX;
|
|
39
|
+
delete process.env.TOTAL_SHARDS;
|
|
40
|
+
|
|
41
|
+
const IBR = require(IBR_DIST).default;
|
|
42
|
+
|
|
43
|
+
class IBRBlobAdapter extends IBR {
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
super();
|
|
46
|
+
this._adapterOptions = options;
|
|
47
|
+
this._adapterCwd = process.cwd();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async onEnd(result) {
|
|
51
|
+
await super.onEnd(result);
|
|
52
|
+
const shard = this._shardIndex;
|
|
53
|
+
const src = path.join(
|
|
54
|
+
this._adapterCwd,
|
|
55
|
+
"blob-report-incremental",
|
|
56
|
+
`incremental-report-${shard}.zip`,
|
|
57
|
+
);
|
|
58
|
+
if (!fs.existsSync(src)) {
|
|
59
|
+
// Mirrors blob: no zip when there was nothing to report.
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const outputDir = path.resolve(
|
|
63
|
+
this._adapterCwd,
|
|
64
|
+
this._adapterOptions.outputDir || "blob-report",
|
|
65
|
+
);
|
|
66
|
+
const fileName = this._adapterOptions.fileName || `report-${shard}.zip`;
|
|
67
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
68
|
+
fs.copyFileSync(src, path.join(outputDir, fileName));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = IBRBlobAdapter;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts
|
|
2
|
+
index 28ef919e2..350fe8b5c 100644
|
|
3
|
+
--- a/tests/playwright-test/playwright-test-fixtures.ts
|
|
4
|
+
+++ b/tests/playwright-test/playwright-test-fixtures.ts
|
|
5
|
+
@@ -143,7 +143,10 @@ async function runPlaywrightTest(childProcess: CommonFixtures['childProcess'], b
|
|
6
|
+
let reporter;
|
|
7
|
+
if (useIntermediateMergeReport) {
|
|
8
|
+
reporter = params.reporter;
|
|
9
|
+
- params.reporter = 'blob';
|
|
10
|
+
+ // PWTEST_IBR_ADAPTER routes the intermediate report through Empirical's
|
|
11
|
+
+ // IncrementalBlobReporter instead of the built-in blob reporter, so this
|
|
12
|
+
+ // suite doubles as a blob-format parity oracle for IBR.
|
|
13
|
+
+ params.reporter = process.env.PWTEST_IBR_ADAPTER || 'blob';
|
|
14
|
+
}
|
|
15
|
+
const reportFile = path.join(baseDir, 'report.json');
|
|
16
|
+
// When we have useIntermediateMergeReport, we want the JSON reporter only at the merge step.
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs upstream Playwright's own runner test suite (tests/playwright-test)
|
|
3
|
+
* through the IncrementalBlobReporter and compares failures against the
|
|
4
|
+
* built-in blob reporter baseline.
|
|
5
|
+
*
|
|
6
|
+
* How: upstream's PWTEST_INTERMEDIATE_BLOB_REPORT=1 mode reroutes every
|
|
7
|
+
* inline scenario through <reporter> -> merge-reports -> json before applying
|
|
8
|
+
* the normal assertions — Playwright's own completeness oracle for the blob
|
|
9
|
+
* format. The fixture patch (intermediate-mode-ibr.patch) lets
|
|
10
|
+
* PWTEST_IBR_ADAPTER swap IBR in as that reporter.
|
|
11
|
+
*
|
|
12
|
+
* Both modes run the same spec files; environment noise cancels in the
|
|
13
|
+
* baseline diff. Any IBR-only failure fails the run — the job stays red
|
|
14
|
+
* until IBR reaches parity on these specs. It staying green is the signal
|
|
15
|
+
* that the built-in blob reporter is safe to drop.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node run.mjs --pw-repo <upstream clone at the pinned tag>
|
|
19
|
+
* [--specs basic.spec.ts,stdio.spec.ts] [--workers N] [--shard i/n]
|
|
20
|
+
*
|
|
21
|
+
* The clone must be bootstrapped (npm ci + npm run build). This script
|
|
22
|
+
* (re)applies the fixture patch idempotently on every run.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
const ORACLE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
// The default sweep is EVERY spec in upstream's tests/playwright-test —
|
|
34
|
+
// IBR is meant to become the default blob reporter, so it has to stand the
|
|
35
|
+
// whole runner suite. Only structurally out-of-scope specs are excluded,
|
|
36
|
+
// each with its reason printed at startup (no silent caps). First matching
|
|
37
|
+
// rule wins.
|
|
38
|
+
const EXCLUSION_RULES = [
|
|
39
|
+
[
|
|
40
|
+
(name) => name.startsWith("playwright.ct-"),
|
|
41
|
+
"component-testing build; no runner-report signal",
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
(name) => name.startsWith("playwright."),
|
|
45
|
+
"drives real browsers (not installed in the oracle env)",
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
(name) => name.startsWith("ui-mode-"),
|
|
49
|
+
"UI mode; needs trace viewer + browsers",
|
|
50
|
+
],
|
|
51
|
+
[
|
|
52
|
+
(name) =>
|
|
53
|
+
name === "to-have-screenshot.spec.ts" ||
|
|
54
|
+
name === "aria-snapshot-file.spec.ts" ||
|
|
55
|
+
name === "update-aria-snapshot.spec.ts",
|
|
56
|
+
"browser screenshots/aria snapshots",
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
(name) => name === "types.spec.ts" || name === "types-2.spec.ts",
|
|
60
|
+
"tsc-only; exercises no runner report",
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
(name) => name === "reporter-html.spec.ts",
|
|
64
|
+
"browser-driven HTML-report UI tests; no blob-format signal (merge " +
|
|
65
|
+
"semantics are covered by reporter-blob.spec.ts) and they busy-loop " +
|
|
66
|
+
"workers under load, wedging the sweep",
|
|
67
|
+
],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// Tests skipped by title via --grep-invert. Reserved for tests that WEDGE
|
|
71
|
+
// the sweep — a busy-looped worker no per-test timeout can interrupt — so
|
|
72
|
+
// exclusion is surgical (exact title match) and every entry carries the
|
|
73
|
+
// observed reason. Everything else in the spec still runs.
|
|
74
|
+
const HANGING_TESTS = [];
|
|
75
|
+
|
|
76
|
+
function discoverSpecs(pwRepo) {
|
|
77
|
+
const specDir = path.join(pwRepo, "tests", "playwright-test");
|
|
78
|
+
const all = fs
|
|
79
|
+
.readdirSync(specDir)
|
|
80
|
+
.filter((f) => f.endsWith(".spec.ts"))
|
|
81
|
+
.sort();
|
|
82
|
+
const included = [];
|
|
83
|
+
const excluded = [];
|
|
84
|
+
for (const name of all) {
|
|
85
|
+
const rule = EXCLUSION_RULES.find(([match]) => match(name));
|
|
86
|
+
if (rule) {
|
|
87
|
+
excluded.push([name, rule[1]]);
|
|
88
|
+
} else {
|
|
89
|
+
included.push(name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { included, excluded };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ADAPTER_PATH = path.join(ORACLE_DIR, "ibr-adapter.js");
|
|
96
|
+
const PATCH_PATH = path.join(ORACLE_DIR, "intermediate-mode-ibr.patch");
|
|
97
|
+
const DEFAULT_IBR_DIST = path.join(
|
|
98
|
+
ORACLE_DIR,
|
|
99
|
+
"../dist/reporter/incremental-blob-reporter.js",
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
function parseArgs(argv) {
|
|
103
|
+
const args = { specs: null, workers: 4, shard: null };
|
|
104
|
+
// Normalize --flag=value into --flag value.
|
|
105
|
+
argv = argv.flatMap((a) =>
|
|
106
|
+
a.startsWith("--") && a.includes("=")
|
|
107
|
+
? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)]
|
|
108
|
+
: [a],
|
|
109
|
+
);
|
|
110
|
+
for (let i = 0; i < argv.length; i++) {
|
|
111
|
+
const arg = argv[i];
|
|
112
|
+
if (arg === "--pw-repo") {
|
|
113
|
+
args.pwRepo = argv[++i];
|
|
114
|
+
} else if (arg === "--specs") {
|
|
115
|
+
args.specs = argv[++i].split(",").filter(Boolean);
|
|
116
|
+
} else if (arg === "--workers") {
|
|
117
|
+
args.workers = Number(argv[++i]);
|
|
118
|
+
} else if (arg === "--shard") {
|
|
119
|
+
const match = /^(\d+)\/(\d+)$/.exec(argv[++i] ?? "");
|
|
120
|
+
if (!match) {
|
|
121
|
+
throw new Error("--shard expects i/n (e.g. --shard 2/4)");
|
|
122
|
+
}
|
|
123
|
+
args.shard = { index: Number(match[1]), total: Number(match[2]) };
|
|
124
|
+
if (args.shard.index < 1 || args.shard.index > args.shard.total) {
|
|
125
|
+
throw new Error(`--shard index out of range: ${argv[i]}`);
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!args.pwRepo) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"--pw-repo <path to upstream playwright clone> is required",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return args;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function run(command, cmdArgs, options) {
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
const child = spawn(command, cmdArgs, {
|
|
142
|
+
...options,
|
|
143
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
144
|
+
});
|
|
145
|
+
let output = "";
|
|
146
|
+
child.stdout.on("data", (d) => {
|
|
147
|
+
output += d.toString();
|
|
148
|
+
});
|
|
149
|
+
child.stderr.on("data", (d) => {
|
|
150
|
+
output += d.toString();
|
|
151
|
+
});
|
|
152
|
+
child.on("close", (code) => resolve({ code, output }));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Failed-test keys ("file:line › suite › title") from a Playwright JSON report. */
|
|
157
|
+
function collectFailures(report) {
|
|
158
|
+
const failures = new Set();
|
|
159
|
+
const visit = (suite, titles) => {
|
|
160
|
+
for (const spec of suite.specs ?? []) {
|
|
161
|
+
for (const test of spec.tests ?? []) {
|
|
162
|
+
if (test.status === "unexpected") {
|
|
163
|
+
failures.add(
|
|
164
|
+
[`${spec.file}:${spec.line}`, ...titles, spec.title].join(" › "),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const child of suite.suites ?? []) {
|
|
170
|
+
visit(child, [...titles, child.title]);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
for (const suite of report.suites ?? []) {
|
|
174
|
+
visit(suite, []);
|
|
175
|
+
}
|
|
176
|
+
return failures;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function applyFixturePatch(pwRepo) {
|
|
180
|
+
const fixtureFile = "tests/playwright-test/playwright-test-fixtures.ts";
|
|
181
|
+
const restore = await run("git", ["checkout", "--", fixtureFile], {
|
|
182
|
+
cwd: pwRepo,
|
|
183
|
+
});
|
|
184
|
+
if (restore.code !== 0) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`git checkout of ${fixtureFile} failed:\n${restore.output}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const apply = await run("git", ["apply", PATCH_PATH], { cwd: pwRepo });
|
|
190
|
+
if (apply.code !== 0) {
|
|
191
|
+
throw new Error(`git apply ${PATCH_PATH} failed:\n${apply.output}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Hard cap per spec-mode run (milliseconds — Playwright's --global-timeout
|
|
196
|
+
// unit). Some upstream tests can busy-loop a worker under load (sync string
|
|
197
|
+
// processing that no per-test timeout can interrupt — the timeouts run on
|
|
198
|
+
// the same blocked event loop); which test wedges is timing-dependent, so
|
|
199
|
+
// title exclusion cannot converge. Playwright's global timeout fires from
|
|
200
|
+
// the main process and SIGKILLs through busy loops. Each spec runs as its
|
|
201
|
+
// own bounded invocation, so a wedge costs one spec's cap, names the spec,
|
|
202
|
+
// and the sweep continues — hangs become inventory instead of aborting the
|
|
203
|
+
// whole mode.
|
|
204
|
+
const SPEC_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
205
|
+
|
|
206
|
+
async function runMode({ pwRepo, spec, workers, mode, ibrDist }) {
|
|
207
|
+
const startedAt = Date.now();
|
|
208
|
+
const jsonOut = path.join(
|
|
209
|
+
fs.mkdtempSync(path.join(os.tmpdir(), "ibr-oracle-")),
|
|
210
|
+
`${mode}.json`,
|
|
211
|
+
);
|
|
212
|
+
const env = {
|
|
213
|
+
...process.env,
|
|
214
|
+
PWTEST_INTERMEDIATE_BLOB_REPORT: "1",
|
|
215
|
+
PLAYWRIGHT_JSON_OUTPUT_NAME: jsonOut,
|
|
216
|
+
FORCE_COLOR: "0",
|
|
217
|
+
};
|
|
218
|
+
if (mode === "ibr") {
|
|
219
|
+
env.PWTEST_IBR_ADAPTER = ADAPTER_PATH;
|
|
220
|
+
env.PWTEST_IBR_DIST = ibrDist;
|
|
221
|
+
}
|
|
222
|
+
// The inner run writes its JSON report at the end, so nothing streams to
|
|
223
|
+
// stdout while it runs. Heartbeat from the test-results dir (the outer
|
|
224
|
+
// harness creates one dir per executed test) so long modes show progress.
|
|
225
|
+
const testResultsDir = path.join(pwRepo, "test-results");
|
|
226
|
+
const heartbeat = setInterval(() => {
|
|
227
|
+
let dirs = 0;
|
|
228
|
+
try {
|
|
229
|
+
dirs = fs.readdirSync(testResultsDir).length;
|
|
230
|
+
} catch {
|
|
231
|
+
// Not created yet.
|
|
232
|
+
}
|
|
233
|
+
const minutes = Math.round((Date.now() - startedAt) / 60_000);
|
|
234
|
+
console.log(
|
|
235
|
+
` [${mode} ${spec}] ~${dirs} tests executed, ${minutes}m elapsed`,
|
|
236
|
+
);
|
|
237
|
+
}, 60_000);
|
|
238
|
+
const { code, output } = await run(
|
|
239
|
+
"npm",
|
|
240
|
+
[
|
|
241
|
+
"run",
|
|
242
|
+
"ttest",
|
|
243
|
+
"--",
|
|
244
|
+
spec,
|
|
245
|
+
`--workers=${workers}`,
|
|
246
|
+
// Absorbs outer-harness flake: a scenario that fails on IBR's real gaps
|
|
247
|
+
// fails every retry, while an infra hiccup passes on retry and is
|
|
248
|
+
// reported "flaky" (not counted as a failure).
|
|
249
|
+
"--retries=2",
|
|
250
|
+
`--global-timeout=${SPEC_TIMEOUT_MS}`,
|
|
251
|
+
// An empty --grep-invert would match (and thus drop) every test, so
|
|
252
|
+
// the flag only appears when there are titles to skip.
|
|
253
|
+
...(HANGING_TESTS.length
|
|
254
|
+
? [`--grep-invert=${HANGING_TESTS.map((h) => h.pattern).join("|")}`]
|
|
255
|
+
: []),
|
|
256
|
+
"--reporter=json",
|
|
257
|
+
],
|
|
258
|
+
{ cwd: pwRepo, env },
|
|
259
|
+
);
|
|
260
|
+
clearInterval(heartbeat);
|
|
261
|
+
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1_000);
|
|
262
|
+
// A globally-timed-out run still writes a valid JSON report with the
|
|
263
|
+
// remaining tests marked "did not run", which would otherwise diff as a
|
|
264
|
+
// false green — report the hang instead of trusting the partial report.
|
|
265
|
+
if (/Timed out waiting .* for the test suite/.test(output)) {
|
|
266
|
+
return { hung: true, elapsedSeconds };
|
|
267
|
+
}
|
|
268
|
+
let report;
|
|
269
|
+
try {
|
|
270
|
+
report = JSON.parse(fs.readFileSync(jsonOut, "utf8"));
|
|
271
|
+
} catch {
|
|
272
|
+
console.error(output.slice(-4000));
|
|
273
|
+
throw new Error(
|
|
274
|
+
`${mode} ${spec}: no JSON report produced (exit code ${code}) — harness broken, see output above`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const stats = report.stats ?? {};
|
|
278
|
+
const totalRun =
|
|
279
|
+
(stats.expected ?? 0) +
|
|
280
|
+
(stats.unexpected ?? 0) +
|
|
281
|
+
(stats.flaky ?? 0) +
|
|
282
|
+
(stats.skipped ?? 0);
|
|
283
|
+
return { failures: collectFailures(report), totalRun, elapsedSeconds };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const args = parseArgs(process.argv.slice(2));
|
|
287
|
+
const pwRepo = path.resolve(args.pwRepo);
|
|
288
|
+
const ibrDist = path.resolve(process.env.PWTEST_IBR_DIST || DEFAULT_IBR_DIST);
|
|
289
|
+
if (!fs.existsSync(ibrDist)) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`IBR dist not found at ${ibrDist} — build playwright-utils first`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await applyFixturePatch(pwRepo);
|
|
296
|
+
|
|
297
|
+
if (!args.specs) {
|
|
298
|
+
const { included, excluded } = discoverSpecs(pwRepo);
|
|
299
|
+
args.specs = included;
|
|
300
|
+
console.log(`Excluded specs (${excluded.length}):`);
|
|
301
|
+
for (const [name, reason] of excluded) {
|
|
302
|
+
console.log(` [excluded] ${name} — ${reason}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (args.shard) {
|
|
306
|
+
// Round-robin slice so slow specs spread across shards.
|
|
307
|
+
args.specs = args.specs.filter(
|
|
308
|
+
(_, i) => i % args.shard.total === args.shard.index - 1,
|
|
309
|
+
);
|
|
310
|
+
console.log(
|
|
311
|
+
`Shard ${args.shard.index}/${args.shard.total}: ${args.specs.length} specs`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
console.log(`Specs: ${args.specs.join(", ")}`);
|
|
316
|
+
for (const { pattern, reason } of HANGING_TESTS) {
|
|
317
|
+
console.log(` [skipped-by-title] /${pattern}/ — ${reason}`);
|
|
318
|
+
}
|
|
319
|
+
// Each spec runs both modes back to back as its own bounded invocation
|
|
320
|
+
// (see SPEC_TIMEOUT_MS); a hang or count mismatch quarantines that spec and
|
|
321
|
+
// the sweep continues.
|
|
322
|
+
const ibrOnly = [];
|
|
323
|
+
const hungSpecs = [];
|
|
324
|
+
const mismatchedSpecs = [];
|
|
325
|
+
let baselineNoise = 0;
|
|
326
|
+
|
|
327
|
+
for (const spec of args.specs) {
|
|
328
|
+
const blob = await runMode({
|
|
329
|
+
pwRepo,
|
|
330
|
+
spec,
|
|
331
|
+
workers: args.workers,
|
|
332
|
+
mode: "blob",
|
|
333
|
+
});
|
|
334
|
+
if (blob.hung) {
|
|
335
|
+
console.log(` [hung] ${spec} (blob mode, ${blob.elapsedSeconds}s)`);
|
|
336
|
+
hungSpecs.push(`${spec} (blob mode)`);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const ibr = await runMode({
|
|
340
|
+
pwRepo,
|
|
341
|
+
spec,
|
|
342
|
+
workers: args.workers,
|
|
343
|
+
mode: "ibr",
|
|
344
|
+
ibrDist,
|
|
345
|
+
});
|
|
346
|
+
if (ibr.hung) {
|
|
347
|
+
console.log(` [hung] ${spec} (ibr mode, ${ibr.elapsedSeconds}s)`);
|
|
348
|
+
hungSpecs.push(`${spec} (ibr mode)`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (blob.totalRun === 0 || blob.totalRun !== ibr.totalRun) {
|
|
352
|
+
console.log(
|
|
353
|
+
` [mismatch] ${spec}: blob ran ${blob.totalRun}, ibr ran ${ibr.totalRun}`,
|
|
354
|
+
);
|
|
355
|
+
mismatchedSpecs.push(spec);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const specIbrOnly = [...ibr.failures]
|
|
359
|
+
.filter((key) => !blob.failures.has(key))
|
|
360
|
+
.sort();
|
|
361
|
+
baselineNoise += blob.failures.size;
|
|
362
|
+
ibrOnly.push(...specIbrOnly);
|
|
363
|
+
console.log(
|
|
364
|
+
` ${spec}: ${blob.totalRun} tests, ` +
|
|
365
|
+
`${blob.failures.size} baseline noise, ${specIbrOnly.length} ibr-only ` +
|
|
366
|
+
`(blob ${blob.elapsedSeconds}s, ibr ${ibr.elapsedSeconds}s)`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log(
|
|
371
|
+
`\nBaseline failures (env noise, cancelled in diff): ${baselineNoise}`,
|
|
372
|
+
);
|
|
373
|
+
console.log(`IBR-only failures: ${ibrOnly.length}`);
|
|
374
|
+
for (const key of ibrOnly.sort()) {
|
|
375
|
+
console.log(` [ibr-only] ${key}`);
|
|
376
|
+
}
|
|
377
|
+
for (const spec of hungSpecs) {
|
|
378
|
+
console.log(` [hung] ${spec}`);
|
|
379
|
+
}
|
|
380
|
+
for (const spec of mismatchedSpecs) {
|
|
381
|
+
console.log(` [mismatch] ${spec}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (ibrOnly.length > 0 || hungSpecs.length > 0 || mismatchedSpecs.length > 0) {
|
|
385
|
+
console.error(
|
|
386
|
+
`\nFAIL: ${ibrOnly.length} upstream test(s) fail only through IBR, ` +
|
|
387
|
+
`${hungSpecs.length} spec run(s) hung, ${mismatchedSpecs.length} ` +
|
|
388
|
+
`spec(s) mismatched. The job stays red until IBR reaches blob parity ` +
|
|
389
|
+
`on these specs.`,
|
|
390
|
+
);
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
console.log(
|
|
394
|
+
"\nOK: IBR at parity with the built-in blob reporter on these specs",
|
|
395
|
+
);
|