@empiricalrun/playwright-utils 0.58.3 → 0.58.5

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.
@@ -9,15 +9,17 @@
9
9
  * format. The fixture patch (intermediate-mode-ibr.patch) lets
10
10
  * PWTEST_IBR_ADAPTER swap IBR in as that reporter.
11
11
  *
12
- * Both modes run the same curated spec files; environment noise cancels in
13
- * the baseline diff. Any IBR-only failure fails the run — the job stays red
14
- * until IBR reaches parity (known gaps today: onStdIO, onError, result-level
15
- * annotations, attachment bodies). It turning green is the signal that the
16
- * built-in blob reporter is safe to drop.
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. The one carve-out is
16
+ * EXPECTED_IBR_ONLY below: failures caused by injecting an external reporter
17
+ * rather than by the blob format, each with the reason it cannot pass, and
18
+ * each policed by a staleness check so the list cannot rot.
17
19
  *
18
20
  * Usage:
19
21
  * node run.mjs --pw-repo <upstream clone at the pinned tag>
20
- * [--specs basic.spec.ts,stdio.spec.ts] [--workers N]
22
+ * [--specs basic.spec.ts,stdio.spec.ts] [--workers N] [--shard i/n]
21
23
  *
22
24
  * The clone must be bootstrapped (npm ci + npm run build). This script
23
25
  * (re)applies the fixture patch idempotently on every run.
@@ -31,18 +33,111 @@ import { fileURLToPath } from "node:url";
31
33
 
32
34
  const ORACLE_DIR = path.dirname(fileURLToPath(import.meta.url));
33
35
 
34
- // Curated, node-only (no browser) runner specs with high report-content
35
- // signal. stdio.spec.ts is the direct probe for the known onStdIO gap.
36
- const DEFAULT_SPECS = [
37
- "basic.spec.ts",
38
- "hooks.spec.ts",
39
- "max-failures.spec.ts",
40
- "stdio.spec.ts",
41
- "test-modifiers.spec.ts",
42
- "test-serial.spec.ts",
43
- "test-step.spec.ts",
36
+ // The default sweep is EVERY spec in upstream's tests/playwright-test —
37
+ // IBR is meant to become the default blob reporter, so it has to stand the
38
+ // whole runner suite. Only structurally out-of-scope specs are excluded,
39
+ // each with its reason printed at startup (no silent caps). First matching
40
+ // rule wins.
41
+ const EXCLUSION_RULES = [
42
+ [
43
+ (name) => name.startsWith("playwright.ct-"),
44
+ "component-testing build; no runner-report signal",
45
+ ],
46
+ [
47
+ (name) => name.startsWith("playwright."),
48
+ "drives real browsers (not installed in the oracle env)",
49
+ ],
50
+ [
51
+ (name) => name.startsWith("ui-mode-"),
52
+ "UI mode; needs trace viewer + browsers",
53
+ ],
54
+ [
55
+ (name) =>
56
+ name === "to-have-screenshot.spec.ts" ||
57
+ name === "aria-snapshot-file.spec.ts" ||
58
+ name === "update-aria-snapshot.spec.ts",
59
+ "browser screenshots/aria snapshots",
60
+ ],
61
+ [
62
+ (name) => name === "types.spec.ts" || name === "types-2.spec.ts",
63
+ "tsc-only; exercises no runner report",
64
+ ],
65
+ [
66
+ (name) => name === "reporter-html.spec.ts",
67
+ "browser-driven HTML-report UI tests; no blob-format signal (merge " +
68
+ "semantics are covered by reporter-blob.spec.ts) and they busy-loop " +
69
+ "workers under load, wedging the sweep",
70
+ ],
71
+ // The two specs below are SLOW, not wedged — measured, they make steady
72
+ // progress and simply do not fit in SPEC_TIMEOUT_MS. The oracle runs one
73
+ // spec per invocation and Playwright keeps a file's tests on a single
74
+ // worker (fullyParallel defaults to false), so a long file runs strictly
75
+ // serially however many --workers it is given. Both were already
76
+ // contributing no signal, just a 10-minute timeout each.
77
+ [
78
+ (name) => name === "only-changed.spec.ts",
79
+ "git-based run selection (--only-changed), not report format; 13 tests " +
80
+ "that each shell out to git take ~13min serially here",
81
+ ],
82
+ [
83
+ (name) => name === "web-server.spec.ts",
84
+ "webServer config and lifecycle, not report format; 39 tests that each " +
85
+ "boot and tear down a server take longer still",
86
+ ],
44
87
  ];
45
88
 
89
+ // Upstream tests that fail through IBR for reasons OUTSIDE the blob format —
90
+ // artifacts of injecting an external reporter into upstream's harness, not
91
+ // gaps in what IBR writes. They are reported and subtracted from the failure
92
+ // count instead of turning the job red.
93
+ //
94
+ // Kept honest by the staleness check at the end: an entry whose spec ran and
95
+ // did NOT fail is reported and fails the run, so a fixed test cannot sit here
96
+ // pretending to be unfixable. Add an entry only with the reason it can never
97
+ // pass; anything that could be fixed belongs in the failure list.
98
+ const EXPECTED_IBR_ONLY = [
99
+ {
100
+ key:
101
+ "cache.spec.ts:53 › should automatically clean cached versions of a " +
102
+ "changed file",
103
+ reason:
104
+ "asserts the transform cache holds exactly one directory; loading IBR " +
105
+ "from a path runs its bundle through the same transform pipeline, so " +
106
+ "the cache gains a directory per module (32 observed). The built-in " +
107
+ "blob reporter ships inside Playwright and never enters that cache.",
108
+ },
109
+ ];
110
+
111
+ /** Spec file name out of a failure key ("cache.spec.ts:53 › title"). */
112
+ function specOfKey(key) {
113
+ return key.slice(0, key.indexOf(":"));
114
+ }
115
+
116
+ // Tests skipped by title via --grep-invert. Reserved for tests that WEDGE
117
+ // the sweep — a busy-looped worker no per-test timeout can interrupt — so
118
+ // exclusion is surgical (exact title match) and every entry carries the
119
+ // observed reason. Everything else in the spec still runs.
120
+ const HANGING_TESTS = [];
121
+
122
+ function discoverSpecs(pwRepo) {
123
+ const specDir = path.join(pwRepo, "tests", "playwright-test");
124
+ const all = fs
125
+ .readdirSync(specDir)
126
+ .filter((f) => f.endsWith(".spec.ts"))
127
+ .sort();
128
+ const included = [];
129
+ const excluded = [];
130
+ for (const name of all) {
131
+ const rule = EXCLUSION_RULES.find(([match]) => match(name));
132
+ if (rule) {
133
+ excluded.push([name, rule[1]]);
134
+ } else {
135
+ included.push(name);
136
+ }
137
+ }
138
+ return { included, excluded };
139
+ }
140
+
46
141
  const ADAPTER_PATH = path.join(ORACLE_DIR, "ibr-adapter.js");
47
142
  const PATCH_PATH = path.join(ORACLE_DIR, "intermediate-mode-ibr.patch");
48
143
  const DEFAULT_IBR_DIST = path.join(
@@ -51,7 +146,7 @@ const DEFAULT_IBR_DIST = path.join(
51
146
  );
52
147
 
53
148
  function parseArgs(argv) {
54
- const args = { specs: DEFAULT_SPECS, workers: 4 };
149
+ const args = { specs: null, workers: 4, shard: null };
55
150
  // Normalize --flag=value into --flag value.
56
151
  argv = argv.flatMap((a) =>
57
152
  a.startsWith("--") && a.includes("=")
@@ -66,6 +161,15 @@ function parseArgs(argv) {
66
161
  args.specs = argv[++i].split(",").filter(Boolean);
67
162
  } else if (arg === "--workers") {
68
163
  args.workers = Number(argv[++i]);
164
+ } else if (arg === "--shard") {
165
+ const match = /^(\d+)\/(\d+)$/.exec(argv[++i] ?? "");
166
+ if (!match) {
167
+ throw new Error("--shard expects i/n (e.g. --shard 2/4)");
168
+ }
169
+ args.shard = { index: Number(match[1]), total: Number(match[2]) };
170
+ if (args.shard.index < 1 || args.shard.index > args.shard.total) {
171
+ throw new Error(`--shard index out of range: ${argv[i]}`);
172
+ }
69
173
  } else {
70
174
  throw new Error(`Unknown argument: ${arg}`);
71
175
  }
@@ -134,7 +238,19 @@ async function applyFixturePatch(pwRepo) {
134
238
  }
135
239
  }
136
240
 
137
- async function runMode({ pwRepo, specs, workers, mode, ibrDist }) {
241
+ // Hard cap per spec-mode run (milliseconds — Playwright's --global-timeout
242
+ // unit). Some upstream tests can busy-loop a worker under load (sync string
243
+ // processing that no per-test timeout can interrupt — the timeouts run on
244
+ // the same blocked event loop); which test wedges is timing-dependent, so
245
+ // title exclusion cannot converge. Playwright's global timeout fires from
246
+ // the main process and SIGKILLs through busy loops. Each spec runs as its
247
+ // own bounded invocation, so a wedge costs one spec's cap, names the spec,
248
+ // and the sweep continues — hangs become inventory instead of aborting the
249
+ // whole mode.
250
+ const SPEC_TIMEOUT_MS = 10 * 60 * 1_000;
251
+
252
+ async function runMode({ pwRepo, spec, workers, mode, ibrDist }) {
253
+ const startedAt = Date.now();
138
254
  const jsonOut = path.join(
139
255
  fs.mkdtempSync(path.join(os.tmpdir(), "ibr-oracle-")),
140
256
  `${mode}.json`,
@@ -149,32 +265,68 @@ async function runMode({ pwRepo, specs, workers, mode, ibrDist }) {
149
265
  env.PWTEST_IBR_ADAPTER = ADAPTER_PATH;
150
266
  env.PWTEST_IBR_DIST = ibrDist;
151
267
  }
268
+ // The inner run writes its JSON report at the end, so nothing streams to
269
+ // stdout while it runs. Heartbeat from the test-results dir (the outer
270
+ // harness creates one dir per executed test) so long modes show progress.
271
+ const testResultsDir = path.join(pwRepo, "test-results");
272
+ const heartbeat = setInterval(() => {
273
+ let dirs = 0;
274
+ try {
275
+ dirs = fs.readdirSync(testResultsDir).length;
276
+ } catch {
277
+ // Not created yet.
278
+ }
279
+ const minutes = Math.round((Date.now() - startedAt) / 60_000);
280
+ console.log(
281
+ ` [${mode} ${spec}] ~${dirs} tests executed, ${minutes}m elapsed`,
282
+ );
283
+ }, 60_000);
152
284
  const { code, output } = await run(
153
285
  "npm",
154
286
  [
155
287
  "run",
156
288
  "ttest",
157
289
  "--",
158
- ...specs,
290
+ spec,
159
291
  `--workers=${workers}`,
160
292
  // Absorbs outer-harness flake: a scenario that fails on IBR's real gaps
161
293
  // fails every retry, while an infra hiccup passes on retry and is
162
294
  // reported "flaky" (not counted as a failure).
163
295
  "--retries=2",
296
+ `--global-timeout=${SPEC_TIMEOUT_MS}`,
297
+ // An empty --grep-invert would match (and thus drop) every test, so
298
+ // the flag only appears when there are titles to skip.
299
+ ...(HANGING_TESTS.length
300
+ ? [`--grep-invert=${HANGING_TESTS.map((h) => h.pattern).join("|")}`]
301
+ : []),
164
302
  "--reporter=json",
165
303
  ],
166
304
  { cwd: pwRepo, env },
167
305
  );
306
+ clearInterval(heartbeat);
307
+ const elapsedSeconds = Math.round((Date.now() - startedAt) / 1_000);
308
+ // A globally-timed-out run still writes a valid JSON report with the
309
+ // remaining tests marked "did not run", which would otherwise diff as a
310
+ // false green — report the hang instead of trusting the partial report.
311
+ if (/Timed out waiting .* for the test suite/.test(output)) {
312
+ return { hung: true, elapsedSeconds };
313
+ }
168
314
  let report;
169
315
  try {
170
316
  report = JSON.parse(fs.readFileSync(jsonOut, "utf8"));
171
317
  } catch {
172
318
  console.error(output.slice(-4000));
173
319
  throw new Error(
174
- `${mode}: no JSON report produced (exit code ${code}) — harness broken, see output above`,
320
+ `${mode} ${spec}: no JSON report produced (exit code ${code}) — harness broken, see output above`,
175
321
  );
176
322
  }
177
- return collectFailures(report);
323
+ const stats = report.stats ?? {};
324
+ const totalRun =
325
+ (stats.expected ?? 0) +
326
+ (stats.unexpected ?? 0) +
327
+ (stats.flaky ?? 0) +
328
+ (stats.skipped ?? 0);
329
+ return { failures: collectFailures(report), totalRun, elapsedSeconds };
178
330
  }
179
331
 
180
332
  const args = parseArgs(process.argv.slice(2));
@@ -188,41 +340,130 @@ if (!fs.existsSync(ibrDist)) {
188
340
 
189
341
  await applyFixturePatch(pwRepo);
190
342
 
343
+ if (!args.specs) {
344
+ const { included, excluded } = discoverSpecs(pwRepo);
345
+ args.specs = included;
346
+ console.log(`Excluded specs (${excluded.length}):`);
347
+ for (const [name, reason] of excluded) {
348
+ console.log(` [excluded] ${name} — ${reason}`);
349
+ }
350
+ }
351
+ if (args.shard) {
352
+ // Round-robin slice so slow specs spread across shards.
353
+ args.specs = args.specs.filter(
354
+ (_, i) => i % args.shard.total === args.shard.index - 1,
355
+ );
356
+ console.log(
357
+ `Shard ${args.shard.index}/${args.shard.total}: ${args.specs.length} specs`,
358
+ );
359
+ }
360
+
191
361
  console.log(`Specs: ${args.specs.join(", ")}`);
192
- console.log("Running baseline (built-in blob reporter)...");
193
- const blobFailures = await runMode({
194
- pwRepo,
195
- specs: args.specs,
196
- workers: args.workers,
197
- mode: "blob",
198
- });
199
- console.log(
200
- `Baseline failures (env noise, cancelled in diff): ${blobFailures.size}`,
201
- );
202
- for (const key of [...blobFailures].sort()) {
203
- console.log(` [baseline] ${key}`);
362
+ for (const { pattern, reason } of HANGING_TESTS) {
363
+ console.log(` [skipped-by-title] /${pattern}/ — ${reason}`);
364
+ }
365
+ for (const { key, reason } of EXPECTED_IBR_ONLY) {
366
+ console.log(` [expected-ibr-only] ${key} — ${reason}`);
204
367
  }
368
+ // Each spec runs both modes back to back as its own bounded invocation
369
+ // (see SPEC_TIMEOUT_MS); a hang or count mismatch quarantines that spec and
370
+ // the sweep continues.
371
+ const ibrOnly = [];
372
+ const hungSpecs = [];
373
+ const mismatchedSpecs = [];
374
+ const expectedByKey = new Map(EXPECTED_IBR_ONLY.map((e) => [e.key, e]));
375
+ const matchedExpected = new Set();
376
+ const comparedSpecs = new Set();
377
+ let baselineNoise = 0;
205
378
 
206
- console.log("Running through IBR adapter...");
207
- const ibrFailures = await runMode({
208
- pwRepo,
209
- specs: args.specs,
210
- workers: args.workers,
211
- mode: "ibr",
212
- ibrDist,
213
- });
379
+ for (const spec of args.specs) {
380
+ const blob = await runMode({
381
+ pwRepo,
382
+ spec,
383
+ workers: args.workers,
384
+ mode: "blob",
385
+ });
386
+ if (blob.hung) {
387
+ console.log(` [hung] ${spec} (blob mode, ${blob.elapsedSeconds}s)`);
388
+ hungSpecs.push(`${spec} (blob mode)`);
389
+ continue;
390
+ }
391
+ const ibr = await runMode({
392
+ pwRepo,
393
+ spec,
394
+ workers: args.workers,
395
+ mode: "ibr",
396
+ ibrDist,
397
+ });
398
+ if (ibr.hung) {
399
+ console.log(` [hung] ${spec} (ibr mode, ${ibr.elapsedSeconds}s)`);
400
+ hungSpecs.push(`${spec} (ibr mode)`);
401
+ continue;
402
+ }
403
+ if (blob.totalRun === 0 || blob.totalRun !== ibr.totalRun) {
404
+ console.log(
405
+ ` [mismatch] ${spec}: blob ran ${blob.totalRun}, ibr ran ${ibr.totalRun}`,
406
+ );
407
+ mismatchedSpecs.push(spec);
408
+ continue;
409
+ }
410
+ comparedSpecs.add(spec);
411
+ const specIbrOnly = [...ibr.failures]
412
+ .filter((key) => !blob.failures.has(key))
413
+ .sort();
414
+ baselineNoise += blob.failures.size;
415
+ const specExpected = specIbrOnly.filter((key) => expectedByKey.has(key));
416
+ for (const key of specExpected) {
417
+ matchedExpected.add(key);
418
+ }
419
+ const specUnexpected = specIbrOnly.filter((key) => !expectedByKey.has(key));
420
+ ibrOnly.push(...specUnexpected);
421
+ console.log(
422
+ ` ${spec}: ${blob.totalRun} tests, ` +
423
+ `${blob.failures.size} baseline noise, ${specUnexpected.length} ibr-only` +
424
+ (specExpected.length ? `, ${specExpected.length} expected` : "") +
425
+ ` (blob ${blob.elapsedSeconds}s, ibr ${ibr.elapsedSeconds}s)`,
426
+ );
427
+ }
214
428
 
215
- const ibrOnly = [...ibrFailures].filter((key) => !blobFailures.has(key)).sort();
429
+ console.log(
430
+ `\nBaseline failures (env noise, cancelled in diff): ${baselineNoise}`,
431
+ );
432
+ console.log(
433
+ `Expected IBR-only failures (not counted): ${matchedExpected.size}`,
434
+ );
216
435
  console.log(`IBR-only failures: ${ibrOnly.length}`);
217
- for (const key of ibrOnly) {
436
+ for (const key of ibrOnly.sort()) {
218
437
  console.log(` [ibr-only] ${key}`);
219
438
  }
439
+ for (const spec of hungSpecs) {
440
+ console.log(` [hung] ${spec}`);
441
+ }
442
+ for (const spec of mismatchedSpecs) {
443
+ console.log(` [mismatch] ${spec}`);
444
+ }
445
+
446
+ // An expected failure whose spec ran and passed means the list is stale —
447
+ // that is a fix to celebrate by deleting the entry, not a silent pass.
448
+ const staleExpected = EXPECTED_IBR_ONLY.filter(
449
+ ({ key }) => comparedSpecs.has(specOfKey(key)) && !matchedExpected.has(key),
450
+ );
451
+ for (const { key } of staleExpected) {
452
+ console.log(` [stale-expected] ${key} — now passes; drop it from the list`);
453
+ }
220
454
 
221
- if (ibrOnly.length > 0) {
455
+ if (
456
+ ibrOnly.length > 0 ||
457
+ hungSpecs.length > 0 ||
458
+ mismatchedSpecs.length > 0 ||
459
+ staleExpected.length > 0
460
+ ) {
222
461
  console.error(
223
- `\nFAIL: ${ibrOnly.length} upstream test(s) fail only through IBR. ` +
224
- `The job stays red until IBR reaches blob parity (known gaps: onStdIO, ` +
225
- `onError, result-level annotations, attachment bodies).`,
462
+ `\nFAIL: ${ibrOnly.length} upstream test(s) fail only through IBR, ` +
463
+ `${hungSpecs.length} spec run(s) hung, ${mismatchedSpecs.length} ` +
464
+ `spec(s) mismatched, ${staleExpected.length} stale expected-failure ` +
465
+ `entr${staleExpected.length === 1 ? "y" : "ies"}. The job stays red ` +
466
+ `until IBR reaches blob parity on these specs.`,
226
467
  );
227
468
  process.exit(1);
228
469
  }