commit 7517df525fa5343fbc6d3e08034e20e7b10fd560
parent b6a859055fd100d0925a1c9895e764d77e567e83
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 10 Oct 2026 01:01:29 -0400
e2e: a timing record per run, and per-spec deltas against the branch's last (e2e speed S2)
Every suite's config writes Playwright's json report beside list
(test-results*/timings.json). scripts/e2e-timings.mjs totals it per spec file,
prints the files slowest first with each one's change against the last run on
the same branch that ran it (the wall time against the last run of the same
spec files), and records the run in the git common dir (e2e-timings/<suite>/
<branch>.json, the last 30), untracked and shared by every worktree.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
9 files changed, 450 insertions(+), 7 deletions(-)
diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts
@@ -169,7 +169,11 @@ export default defineConfig({
// for retries, and passes an explicit --retries=0 so its failures stay
// comparable to a serial run's. See scripts/run-sharded-e2e.mjs.
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results/timings.json" }]],
outputDir: "test-results/",
// Serial, one worker — deliberate, and not a performance oversight. Three
// pieces of shared mutable state are global to the whole run, so two workers
diff --git a/export/playwright.2origin.config.ts b/export/playwright.2origin.config.ts
@@ -29,7 +29,11 @@ export default defineConfig({
testDir: "./e2e-2origin",
timeout: 60_000,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results-2origin/timings.json" }]],
outputDir: "test-results-2origin/",
fullyParallel: false,
workers: 1,
diff --git a/export/playwright.config.ts b/export/playwright.config.ts
@@ -87,7 +87,11 @@ export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results/timings.json" }]],
outputDir: "test-results/",
fullyParallel: false,
workers: 1,
diff --git a/export/playwright.hub.config.ts b/export/playwright.hub.config.ts
@@ -48,7 +48,11 @@ export default defineConfig({
testDir: "./e2e-hub",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results-hub/timings.json" }]],
outputDir: "test-results-hub/",
fullyParallel: false,
workers: 1,
diff --git a/export/playwright.report.config.ts b/export/playwright.report.config.ts
@@ -29,7 +29,11 @@ export default defineConfig({
testDir: "./e2e-report",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results-report/timings.json" }]],
outputDir: "test-results-report/",
fullyParallel: false,
workers: 1,
diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts
@@ -92,7 +92,11 @@ export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results/timings.json" }]],
outputDir: "test-results/",
fullyParallel: false,
workers: 1,
diff --git a/scripts/e2e-timings.mjs b/scripts/e2e-timings.mjs
@@ -0,0 +1,242 @@
+#!/usr/bin/env node
+// PER-SPEC TIMINGS FOR AN E2E RUN, against the last run on the same branch.
+//
+// node scripts/e2e-timings.mjs [<timings.json>] [--suite <name>] [--top <n>] [--no-record]
+//
+// Every suite's Playwright config writes a `json` report beside `list`
+// (`<package>/test-results/timings.json`; the hub's, report's and 2origin's in
+// their own test-results-* dirs). This totals it per spec FILE — every result
+// of every test, retries included, since that is what the run spent — prints
+// the files slowest first, each with its change against the last run of the
+// same suite on the same branch that ran it (the wall time against the last
+// run of the same spec files), and records the run so the next one has a
+// baseline. A spec that quietly grows a 60 s wait shows up here as a delta.
+//
+// THE HISTORY lives in the repository's git common dir
+// (`<.git>/e2e-timings/<suite>/<branch>.json`, the last 30 runs): untracked by
+// construction, shared by every worktree of the checkout, so a branch's
+// baseline is found wherever its next run happens. A report already recorded
+// (the same start time) is not recorded twice, and is compared with the runs
+// before it.
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SELF = fileURLToPath(import.meta.url);
+const REPO_ROOT = path.resolve(path.dirname(SELF), "..");
+export const HISTORY_KEEP = 30;
+
+// --------------------------------------------------------------- the report
+
+/**
+ * Per-spec totals from a Playwright JSON report: `{startTime, wallMs, totals,
+ * specs: {[file]: {tests, passed, failed, flaky, skipped, ms}}}`.
+ */
+export function summarize(report) {
+ const specs = {};
+ const totals = { tests: 0, passed: 0, failed: 0, flaky: 0, skipped: 0 };
+ const walk = (suite, file) => {
+ const f = suite.file ?? file;
+ for (const spec of suite.specs ?? []) {
+ const name = spec.file ?? f;
+ const row = (specs[name] ??= { tests: 0, passed: 0, failed: 0, flaky: 0, skipped: 0, ms: 0 });
+ for (const t of spec.tests ?? []) {
+ row.tests += 1;
+ totals.tests += 1;
+ const kind =
+ t.status === "expected" ? "passed"
+ : t.status === "unexpected" ? "failed"
+ : t.status === "flaky" ? "flaky"
+ : "skipped";
+ row[kind] += 1;
+ totals[kind] += 1;
+ for (const r of t.results ?? []) row.ms += Number(r.duration) || 0;
+ }
+ }
+ for (const child of suite.suites ?? []) walk(child, f);
+ };
+ for (const s of report.suites ?? []) walk(s, s.file);
+ return {
+ startTime: report.stats?.startTime ?? null,
+ wallMs: Number(report.stats?.duration) || 0,
+ totals,
+ specs,
+ };
+}
+
+// ------------------------------------------------------------------ history
+
+function gitOut(args, cwd) {
+ try {
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
+ } catch {
+ return null;
+ }
+}
+
+export function defaultHistoryDir(cwd = REPO_ROOT) {
+ const common = gitOut(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
+ return common ? path.join(common, "e2e-timings") : null;
+}
+
+const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]+/g, "_");
+
+export function historyFile(dir, suite, branch) {
+ return path.join(dir, safe(suite), `${safe(branch)}.json`);
+}
+
+export function readHistory(file) {
+ try {
+ const h = JSON.parse(fs.readFileSync(file, "utf8"));
+ return Array.isArray(h?.runs) ? h.runs : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Where `run` goes: the runs recorded before it (`earlier`, oldest first) and
+ * the history with it appended — or unchanged, when it is already there.
+ */
+export function placeRun(runs, run) {
+ const idx = run.startTime == null ? -1 : runs.findIndex((r) => r.startTime === run.startTime);
+ if (idx >= 0) return { earlier: runs.slice(0, idx), runs, recorded: false };
+ return { earlier: runs, runs: [...runs, run].slice(-HISTORY_KEEP), recorded: true };
+}
+
+const specSet = (r) => Object.keys(r?.specs ?? {}).sort().join("\n");
+
+/**
+ * The baselines: the last earlier run of the SAME spec files (for the wall
+ * time — a one-spec run is no baseline for a full suite), and per spec the last
+ * earlier run that ran it.
+ */
+export function baselines(earlier, run) {
+ const same = [...earlier].reverse().find((r) => specSet(r) === specSet(run)) ?? null;
+ const perSpec = {};
+ for (const file of Object.keys(run.specs)) {
+ const r = [...earlier].reverse().find((e) => e.specs?.[file]);
+ if (r) perSpec[file] = r.specs[file];
+ }
+ return { same, perSpec, any: earlier.length > 0 };
+}
+
+export function writeHistory(file, runs) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const tmp = `${file}.${process.pid}.tmp`;
+ fs.writeFileSync(tmp, `${JSON.stringify({ runs }, null, 1)}\n`);
+ fs.renameSync(tmp, file);
+}
+
+// ------------------------------------------------------------------- output
+
+function secs(ms) {
+ const s = ms / 1000;
+ return s >= 600 ? `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, "0")}s` : `${s.toFixed(1)}s`;
+}
+
+function delta(ms) {
+ const sign = ms > 0 ? "+" : ms < 0 ? "−" : "±";
+ return `${sign}${secs(Math.abs(ms))}`;
+}
+
+/** The printed table, as lines. */
+export function render({ suite, branch, head, run, earlier = [], top = Infinity }) {
+ const out = [];
+ const stamp = (r) => (r.startTime ? `${r.startTime.replace("T", " ").slice(0, 16)} UTC` : "?");
+ out.push(`e2e timings — ${suite} on ${branch}${head ? ` (${head.slice(0, 8)})` : ""}, ${stamp(run)}, wall ${secs(run.wallMs)}`);
+ const t = run.totals;
+ out.push(
+ ` ${t.tests} tests: ${t.passed} passed, ${t.failed} failed` +
+ `${t.flaky ? `, ${t.flaky} flaky` : ""}, ${t.skipped} skipped`,
+ );
+ const { same, perSpec, any } = baselines(earlier, run);
+ if (same) {
+ out.push(` against ${stamp(same)}${same.head ? ` (${same.head.slice(0, 8)})` : ""}, the same specs: wall ${delta(run.wallMs - same.wallMs)}`);
+ } else if (any) {
+ out.push(" no earlier run of these same specs on this branch: each spec against the last run that had it");
+ } else {
+ out.push(" no earlier run of this suite on this branch: this one is the baseline");
+ }
+ out.push("");
+ const rows = Object.entries(run.specs).sort((a, b) => b[1].ms - a[1].ms || a[0].localeCompare(b[0]));
+ const width = Math.max(4, ...rows.map(([f]) => f.length));
+ out.push(` ${"spec".padEnd(width)} tests time${any ? " Δ" : ""}`);
+ for (const [file, r] of rows.slice(0, top)) {
+ const was = perSpec[file];
+ const d = !any ? "" : was ? delta(r.ms - was.ms).padStart(10) : " new";
+ const bad = r.failed ? ` (${r.failed} failed)` : "";
+ out.push(` ${file.padEnd(width)} ${String(r.tests).padStart(5)} ${secs(r.ms).padStart(8)}${d}${bad}`);
+ }
+ if (rows.length > top) out.push(` … ${rows.length - top} more`);
+ return out;
+}
+
+// ---------------------------------------------------------------------- CLI
+
+function parseArgs(argv) {
+ const opts = { report: null, suite: null, top: Infinity, record: true, historyDir: null, branch: null };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === "--suite") opts.suite = argv[++i];
+ else if (a === "--top") opts.top = Number(argv[++i]);
+ else if (a === "--no-record") opts.record = false;
+ else if (a === "--history-dir") opts.historyDir = argv[++i];
+ else if (a === "--branch") opts.branch = argv[++i];
+ else if (a.startsWith("--")) throw new Error(`unknown flag '${a}'`);
+ else opts.report = a;
+ }
+ return opts;
+}
+
+// `editor/test-results/timings.json` → "editor"; `export/test-results-hub/…` → "export-hub".
+export function suiteFromPath(file) {
+ const dir = path.basename(path.dirname(file));
+ const pkg = path.basename(path.dirname(path.dirname(file)));
+ const extra = dir.startsWith("test-results-") ? `-${dir.slice("test-results-".length)}` : "";
+ return `${pkg}${extra}`;
+}
+
+function main(argv) {
+ const opts = parseArgs(argv);
+ const report = path.resolve(opts.report ?? path.join(REPO_ROOT, "editor", "test-results", "timings.json"));
+ let json;
+ try {
+ json = JSON.parse(fs.readFileSync(report, "utf8"));
+ } catch (err) {
+ process.stderr.write(`e2e-timings: cannot read ${report} (${err.code ?? err.message}) — run the suite first\n`);
+ return 1;
+ }
+ const suite = opts.suite ?? suiteFromPath(report);
+ const cwd = path.dirname(report);
+ const branch = opts.branch ?? gitOut(["rev-parse", "--abbrev-ref", "HEAD"], cwd) ?? "unknown";
+ const head = gitOut(["rev-parse", "HEAD"], cwd);
+ const run = { ...summarize(json), head };
+ const dir = opts.historyDir ?? defaultHistoryDir(cwd);
+ let earlier = [];
+ let note = "";
+ if (dir) {
+ const file = historyFile(dir, suite, branch);
+ const placed = placeRun(readHistory(file), run);
+ earlier = placed.earlier;
+ if (opts.record && placed.recorded) {
+ writeHistory(file, placed.runs);
+ note = `recorded in ${file}`;
+ } else if (!placed.recorded) {
+ note = "this run was already recorded";
+ }
+ }
+ const lines = render({ suite, branch, head, run, earlier, top: opts.top });
+ process.stdout.write(`${lines.join("\n")}\n${note ? `\n${note}\n` : ""}`);
+ return 0;
+}
+
+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(SELF)) {
+ try {
+ process.exit(main(process.argv.slice(2)));
+ } catch (err) {
+ process.stderr.write(`e2e-timings: ${err?.message ?? err}\n`);
+ process.exit(2);
+ }
+}
diff --git a/scripts/e2e-timings.test.mjs b/scripts/e2e-timings.test.mjs
@@ -0,0 +1,175 @@
+// scripts/e2e-timings.mjs — per-spec totals from a Playwright JSON report, and
+// the delta against the branch's last run. The reports here are synthetic, in
+// the shape Playwright's `json` reporter writes.
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { after, test } from "node:test";
+import { fileURLToPath } from "node:url";
+import {
+ HISTORY_KEEP,
+ baselines,
+ historyFile,
+ placeRun,
+ readHistory,
+ render,
+ suiteFromPath,
+ summarize,
+ writeHistory,
+} from "./e2e-timings.mjs";
+
+const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "e2e-timings.mjs");
+
+const made = [];
+after(() => {
+ for (const dir of made) fs.rmSync(dir, { recursive: true, force: true });
+});
+function tmp() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-timings-test-"));
+ made.push(dir);
+ return dir;
+}
+
+const result = (duration, status = "passed") => ({ duration, status, retry: 0 });
+const t = (status, ...results) => ({ status, projectName: "chromium", results });
+
+// Two files; one nests a describe; one test failed, one retried to flaky, one skipped.
+function report({ pacingMs = 60_000, startTime = "2026-10-09T20:40:00.000Z", wall = 90_000 } = {}) {
+ return {
+ config: {},
+ errors: [],
+ stats: { startTime, duration: wall, expected: 3, unexpected: 1, flaky: 1, skipped: 1 },
+ suites: [
+ {
+ title: "pacing.spec.ts",
+ file: "pacing.spec.ts",
+ specs: [
+ { title: "a live 429 backs off", file: "pacing.spec.ts", tests: [t("expected", result(pacingMs))] },
+ { title: "deferred", file: "pacing.spec.ts", tests: [t("unexpected", result(4_000, "failed"))] },
+ ],
+ },
+ {
+ title: "channel-work.spec.ts",
+ file: "channel-work.spec.ts",
+ specs: [{ title: "top", file: "channel-work.spec.ts", tests: [t("expected", result(1_500))] }],
+ suites: [
+ {
+ title: "inline",
+ file: "channel-work.spec.ts",
+ specs: [
+ {
+ title: "retried",
+ file: "channel-work.spec.ts",
+ tests: [t("flaky", result(2_000, "failed"), result(1_000))],
+ },
+ { title: "skipped", file: "channel-work.spec.ts", tests: [t("skipped", result(0, "skipped"))] },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+}
+
+test("summarize totals every result per spec file, nested describes and retries included", () => {
+ const s = summarize(report());
+ assert.equal(s.startTime, "2026-10-09T20:40:00.000Z");
+ assert.equal(s.wallMs, 90_000);
+ assert.deepEqual(s.totals, { tests: 5, passed: 2, failed: 1, flaky: 1, skipped: 1 });
+ assert.deepEqual(s.specs["pacing.spec.ts"], { tests: 2, passed: 1, failed: 1, flaky: 0, skipped: 0, ms: 64_000 });
+ assert.deepEqual(s.specs["channel-work.spec.ts"], { tests: 3, passed: 1, failed: 0, flaky: 1, skipped: 1, ms: 4_500 });
+});
+
+test("placeRun: earlier is every run before this one; a run already recorded is not added twice", () => {
+ const a = { startTime: "a", specs: {} };
+ const b = { startTime: "b", specs: {} };
+ const first = placeRun([], a);
+ assert.deepEqual(first.earlier, []);
+ assert.deepEqual(first.runs, [a]);
+ const second = placeRun(first.runs, b);
+ assert.deepEqual(second.earlier, [a]);
+ assert.deepEqual(second.runs, [a, b]);
+ const again = placeRun(second.runs, { ...b });
+ assert.equal(again.recorded, false);
+ assert.deepEqual(again.earlier, [a]);
+ assert.equal(again.runs.length, 2);
+ let runs = [];
+ for (let i = 0; i < HISTORY_KEEP + 5; i++) runs = placeRun(runs, { startTime: String(i) }).runs;
+ assert.equal(runs.length, HISTORY_KEEP);
+ assert.equal(runs[0].startTime, "5");
+});
+
+const row = (ms) => ({ tests: 1, passed: 1, failed: 0, flaky: 0, skipped: 0, ms });
+
+test("baselines: the wall against the last run of the same specs; each spec against its last run", () => {
+ const full1 = { startTime: "1", wallMs: 100, specs: { "a.spec.ts": row(10), "b.spec.ts": row(20) } };
+ const one = { startTime: "2", wallMs: 5, specs: { "a.spec.ts": row(4) } };
+ const now = { startTime: "3", wallMs: 90, specs: { "a.spec.ts": row(3), "b.spec.ts": row(25) } };
+ const b = baselines([full1, one], now);
+ assert.equal(b.same, full1);
+ assert.deepEqual(b.perSpec, { "a.spec.ts": row(4), "b.spec.ts": row(20) });
+ assert.equal(baselines([one], now).same, null);
+});
+
+test("render: slowest first, a signed delta per spec, a spec never run before is new", () => {
+ const before = { ...summarize(report()), head: "aaaaaaaa11" };
+ const now = summarize(report({ pacingMs: 10_000, startTime: "2026-10-10T01:00:00.000Z", wall: 40_000 }));
+ const lines = render({ suite: "editor", branch: "r20/e2e-speed", head: "bbbbbbbb22", run: now, earlier: [before] });
+ assert.equal(lines[0], "e2e timings — editor on r20/e2e-speed (bbbbbbbb), 2026-10-10 01:00 UTC, wall 40.0s");
+ assert.equal(lines[1], " 5 tests: 2 passed, 1 failed, 1 flaky, 1 skipped");
+ assert.equal(lines[2], " against 2026-10-09 20:40 UTC (aaaaaaaa), the same specs: wall −50.0s");
+ const body = lines.slice(4);
+ assert.match(body[1], /^ {2}pacing\.spec\.ts\s+2\s+14\.0s\s+−50\.0s {2}\(1 failed\)$/);
+ assert.match(body[2], /^ {2}channel-work\.spec\.ts\s+3\s+4\.5s\s+±0\.0s$/);
+
+ now.specs["new.spec.ts"] = row(500);
+ const mixed = render({ suite: "editor", branch: "b", head: null, run: now, earlier: [before] });
+ assert.equal(mixed[2], " no earlier run of these same specs on this branch: each spec against the last run that had it");
+ assert.match(mixed.at(-1), /^ {2}new\.spec\.ts\s+1\s+0\.5s\s+new$/);
+});
+
+test("render with no earlier run says this run is the baseline", () => {
+ const lines = render({ suite: "umtool", branch: "main", head: null, run: summarize(report()), earlier: [] });
+ assert.equal(lines[2], " no earlier run of this suite on this branch: this one is the baseline");
+ assert.doesNotMatch(lines[4], /Δ/);
+});
+
+test("the suite name comes from where the report lives", () => {
+ assert.equal(suiteFromPath("/x/editor/test-results/timings.json"), "editor");
+ assert.equal(suiteFromPath("/x/export/test-results-hub/timings.json"), "export-hub");
+ assert.equal(suiteFromPath("/x/export/test-results-report/timings.json"), "export-report");
+});
+
+test("history round-trips, and a garbled file reads as no history", () => {
+ const dir = tmp();
+ const file = historyFile(dir, "editor", "r20/e2e-speed");
+ assert.equal(path.basename(file), "r20_e2e-speed.json");
+ assert.deepEqual(readHistory(file), []);
+ writeHistory(file, [{ startTime: "a" }]);
+ assert.deepEqual(readHistory(file), [{ startTime: "a" }]);
+ fs.writeFileSync(file, "{nope");
+ assert.deepEqual(readHistory(file), []);
+});
+
+test("the CLI records the first run, then prints the second against it", () => {
+ const dir = tmp();
+ const reportFile = path.join(dir, "editor", "test-results", "timings.json");
+ fs.mkdirSync(path.dirname(reportFile), { recursive: true });
+ const hist = path.join(dir, "hist");
+ const run = () =>
+ execFileSync(process.execPath, [SCRIPT, reportFile, "--history-dir", hist, "--branch", "b"], {
+ encoding: "utf8",
+ });
+ fs.writeFileSync(reportFile, JSON.stringify(report()));
+ const one = run();
+ assert.match(one, /this one is the baseline/);
+ assert.match(one, /recorded in /);
+ assert.match(run(), /this run was already recorded/);
+ fs.writeFileSync(reportFile, JSON.stringify(report({ pacingMs: 5_000, startTime: "2026-10-10T02:00:00.000Z" })));
+ const two = run();
+ assert.match(two, /against 2026-10-09 20:40 UTC, the same specs: wall ±0\.0s/);
+ assert.match(two, /pacing\.spec\.ts\s+2\s+9\.0s\s+−55\.0s/);
+ assert.equal(readHistory(historyFile(hist, "editor", "b")).length, 2);
+});
diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts
@@ -75,7 +75,9 @@ export default defineConfig({
testMatch: /.*\.spec\.ts/,
fullyParallel: false,
workers: 1,
- reporter: [["list"]],
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: [["list"], ["json", { outputFile: "test-results/timings.json" }]],
use: {
baseURL: `http://localhost:${PORT}`,
trace: "retain-on-failure",