@empiricalrun/playwright-utils 0.58.1 → 0.58.2

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.
@@ -29,6 +29,48 @@ export interface RunResult {
29
29
  stderr: string;
30
30
  }
31
31
  export type WaitFor = "first-passed" | "first-failed";
32
+ /** A `@@ NAME @@` line printed by a fixture suite, with its offset from run start. */
33
+ export interface Marker {
34
+ name: string;
35
+ atMs: number;
36
+ }
37
+ export interface SignalRunResult extends RunResult {
38
+ exitCode: number | null;
39
+ exitSignal: NodeJS.Signals | null;
40
+ markers: Marker[];
41
+ /** Offset from run start at which the first signal was delivered. */
42
+ signalSentAtMs: number;
43
+ /** Wall clock at run start, so `atMs` offsets can be compared to file mtimes. */
44
+ startedAtEpochMs: number;
45
+ }
46
+ export interface SignalRunOptions {
47
+ workers: number;
48
+ /**
49
+ * Signal as soon as this `@@ MARKER @@` is printed. Preferred over `waitFor`:
50
+ * it pins the signal to a known point in the run instead of a sleep.
51
+ */
52
+ waitForMarker?: string;
53
+ /** Legacy trigger: wait for the first pass/fail line in stdout. */
54
+ waitFor?: WaitFor;
55
+ /** Settling delay between the trigger and the signal. */
56
+ delayBeforeSignalMs?: number;
57
+ /** Defaults to SIGINT, which is what production sends. */
58
+ signal?: NodeJS.Signals;
59
+ /** Send the same signal again this long after the first one. */
60
+ secondSignalAfterMs?: number;
61
+ /** SIGKILL the group this long after the first signal, like the cmd.ts grace cap. */
62
+ killAfterMs?: number;
63
+ /** How long to wait for the trigger before giving up and signalling anyway. */
64
+ triggerTimeoutMs?: number;
65
+ }
66
+ /**
67
+ * Spawns `playwright test` with the IBR reporter in its own process group,
68
+ * signals the group mid-run, and waits for exit.
69
+ *
70
+ * The process group and the signal both match production: `cmd.ts` spawns the
71
+ * runner detached and does `process.kill(-pid, signal)`.
72
+ */
73
+ export declare function runPlaywrightWithSignal(ctx: IBRTestContext, opts: SignalRunOptions): Promise<SignalRunResult>;
32
74
  /**
33
75
  * Spawns `playwright test` with the IBR reporter, waits for a test outcome,
34
76
  * sends SIGINT, and waits for the process to exit.
@@ -39,6 +81,8 @@ export declare function runPlaywrightWithSigint(ctx: IBRTestContext, opts: {
39
81
  workers: number;
40
82
  waitFor: WaitFor;
41
83
  }): Promise<RunResult>;
84
+ /** Marker names in the order they were printed. */
85
+ export declare function markerNames(result: SignalRunResult): string[];
42
86
  export declare function runPlaywrightList(ctx: TestCaseSyncTestContext): Promise<RunResult>;
43
87
  export interface BlobEvent {
44
88
  method: string;
@@ -1 +1 @@
1
- {"version":3,"file":"harness.d.ts","sourceRoot":"","sources":["../../src/reporter/harness.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAErD;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IACzD,GAAG,EAAE,cAAc,CAAC;IACpB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC,CAaD;AAqCD,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IACrE,GAAG,EAAE,uBAAuB,CAAC;IAC7B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC,CAaD;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAI3E;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,OAAO,GAAG,cAAc,GAAG,cAAc,CAAC;AAOtD;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAC1C,OAAO,CAAC,SAAS,CAAC,CAiDpB;AAED,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,uBAAuB,GAC3B,OAAO,CAAC,SAAS,CAAC,CA6BpB;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,SAAS,EAAE,CAAC,CAUtB"}
1
+ {"version":3,"file":"harness.d.ts","sourceRoot":"","sources":["../../src/reporter/harness.ts"],"names":[],"mappings":"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"}
@@ -7,7 +7,9 @@ exports.sleep = sleep;
7
7
  exports.setupFixture = setupFixture;
8
8
  exports.setupTestCaseSyncFixture = setupTestCaseSyncFixture;
9
9
  exports.cleanBlobArtifacts = cleanBlobArtifacts;
10
+ exports.runPlaywrightWithSignal = runPlaywrightWithSignal;
10
11
  exports.runPlaywrightWithSigint = runPlaywrightWithSigint;
12
+ exports.markerNames = markerNames;
11
13
  exports.runPlaywrightList = runPlaywrightList;
12
14
  exports.readBlobEvents = readBlobEvents;
13
15
  const zip_1 = require("@empiricalrun/r2-uploader/zip");
@@ -85,15 +87,18 @@ const WAIT_FOR_PATTERNS = {
85
87
  "first-passed": /passed|✓/,
86
88
  "first-failed": /failed|✘|×/,
87
89
  };
90
+ const MARKER_RE = /@@ ([A-Z0-9-]+) @@/g;
88
91
  /**
89
- * Spawns `playwright test` with the IBR reporter, waits for a test outcome,
90
- * sends SIGINT, and waits for the process to exit.
92
+ * Spawns `playwright test` with the IBR reporter in its own process group,
93
+ * signals the group mid-run, and waits for exit.
91
94
  *
92
- * Returns captured stdout/stderr.
95
+ * The process group and the signal both match production: `cmd.ts` spawns the
96
+ * runner detached and does `process.kill(-pid, signal)`.
93
97
  */
94
- async function runPlaywrightWithSigint(ctx, opts) {
95
- const { workers, waitFor } = opts;
96
- const pattern = WAIT_FOR_PATTERNS[waitFor];
98
+ async function runPlaywrightWithSignal(ctx, opts) {
99
+ const { workers, waitForMarker, waitFor, delayBeforeSignalMs = 0, signal = "SIGINT", secondSignalAfterMs, killAfterMs, triggerTimeoutMs = 30_000, } = opts;
100
+ const startedAt = Date.now();
101
+ const since = () => Date.now() - startedAt;
97
102
  const child = (0, child_process_1.spawn)(PLAYWRIGHT_BIN, ["test", `--workers=${workers}`], {
98
103
  cwd: ctx.tempDir,
99
104
  env: {
@@ -101,40 +106,120 @@ async function runPlaywrightWithSigint(ctx, opts) {
101
106
  LOCAL_TEST: "false",
102
107
  SHARD_INDEX: "1",
103
108
  TOTAL_SHARDS: "1",
109
+ FORCE_COLOR: "0",
104
110
  },
105
111
  stdio: ["pipe", "pipe", "pipe"],
106
112
  detached: true,
107
113
  });
108
114
  let stdout = "";
109
115
  let stderr = "";
110
- child.stdout?.on("data", (data) => {
111
- stdout += data.toString();
112
- });
113
- child.stderr?.on("data", (data) => {
114
- stderr += data.toString();
115
- });
116
- // Wait for at least one test to complete before sending SIGINT
117
- await new Promise((resolve) => {
118
- const checkInterval = setInterval(() => {
119
- if (pattern.test(stdout)) {
120
- clearInterval(checkInterval);
121
- resolve(true);
122
- }
123
- }, 200);
124
- setTimeout(() => {
125
- clearInterval(checkInterval);
126
- resolve(false);
127
- }, 30_000);
116
+ const markers = [];
117
+ const collect = (chunk, isStderr) => {
118
+ const text = chunk.toString();
119
+ if (isStderr) {
120
+ stderr += text;
121
+ }
122
+ else {
123
+ stdout += text;
124
+ }
125
+ for (const match of text.matchAll(MARKER_RE)) {
126
+ markers.push({ name: match[1], atMs: since() });
127
+ }
128
+ };
129
+ child.stdout?.on("data", (data) => collect(data, false));
130
+ child.stderr?.on("data", (data) => collect(data, true));
131
+ let exitCode = null;
132
+ let exitSignal = null;
133
+ let hasExited = false;
134
+ const closed = new Promise((resolve) => {
135
+ child.on("close", (code, sig) => {
136
+ hasExited = true;
137
+ exitCode = code;
138
+ exitSignal = sig;
139
+ resolve();
140
+ });
128
141
  });
129
- // Waiting for 1 sec to ensure second test has started
130
- await sleep(1_000);
131
- // Send SIGINT to the process group (negative PID) so all processes
132
- // (Playwright + browsers) receive it, matching production behavior.
133
- process.kill(-child.pid, "SIGINT");
134
- await new Promise((resolve) => {
135
- child.on("close", () => resolve());
142
+ const signalGroup = (sig) => {
143
+ if (hasExited || !child.pid) {
144
+ return;
145
+ }
146
+ try {
147
+ process.kill(-child.pid, sig);
148
+ }
149
+ catch {
150
+ // The group is already gone.
151
+ }
152
+ };
153
+ const pattern = waitFor ? WAIT_FOR_PATTERNS[waitFor] : undefined;
154
+ const isTriggered = () => waitForMarker
155
+ ? markers.some((m) => m.name === waitForMarker)
156
+ : pattern
157
+ ? pattern.test(stdout)
158
+ : true;
159
+ await Promise.race([
160
+ new Promise((resolve) => {
161
+ const interval = setInterval(() => {
162
+ if (isTriggered()) {
163
+ clearInterval(interval);
164
+ resolve();
165
+ }
166
+ }, 50);
167
+ setTimeout(() => {
168
+ clearInterval(interval);
169
+ resolve();
170
+ }, triggerTimeoutMs);
171
+ }),
172
+ closed,
173
+ ]);
174
+ if (delayBeforeSignalMs > 0) {
175
+ await sleep(delayBeforeSignalMs);
176
+ }
177
+ const signalSentAtMs = since();
178
+ signalGroup(signal);
179
+ const timers = [];
180
+ if (secondSignalAfterMs !== undefined) {
181
+ timers.push(setTimeout(() => signalGroup(signal), secondSignalAfterMs));
182
+ }
183
+ if (killAfterMs !== undefined) {
184
+ timers.push(setTimeout(() => signalGroup("SIGKILL"), killAfterMs));
185
+ }
186
+ // Backstop: never leave a runner behind if the suite misbehaves.
187
+ timers.push(setTimeout(() => signalGroup("SIGKILL"), 120_000));
188
+ try {
189
+ await closed;
190
+ }
191
+ finally {
192
+ for (const timer of timers) {
193
+ clearTimeout(timer);
194
+ }
195
+ }
196
+ return {
197
+ stdout,
198
+ stderr,
199
+ exitCode,
200
+ exitSignal,
201
+ markers,
202
+ signalSentAtMs,
203
+ startedAtEpochMs: startedAt,
204
+ };
205
+ }
206
+ /**
207
+ * Spawns `playwright test` with the IBR reporter, waits for a test outcome,
208
+ * sends SIGINT, and waits for the process to exit.
209
+ *
210
+ * Returns captured stdout/stderr.
211
+ */
212
+ async function runPlaywrightWithSigint(ctx, opts) {
213
+ return runPlaywrightWithSignal(ctx, {
214
+ workers: opts.workers,
215
+ waitFor: opts.waitFor,
216
+ // Waiting for 1 sec to ensure second test has started
217
+ delayBeforeSignalMs: 1_000,
136
218
  });
137
- return { stdout, stderr };
219
+ }
220
+ /** Marker names in the order they were printed. */
221
+ function markerNames(result) {
222
+ return result.markers.map((m) => m.name);
138
223
  }
139
224
  async function runPlaywrightList(ctx) {
140
225
  const child = (0, child_process_1.spawn)(PLAYWRIGHT_BIN, ["test", "--list"], {
@@ -31,7 +31,13 @@ declare class IncrementalBlobReporter implements Reporter {
31
31
  private _removeSignalHandler;
32
32
  private _buildSyntheticResult;
33
33
  private _finalizeAndMaybeUpload;
34
- private _finalizeReport;
34
+ /**
35
+ * Computes the lines to write, without touching `_reportLines`: recording
36
+ * continues after the SIGINT salvage, and the onEnd pass re-finalizes from
37
+ * the full stream — mutating here would make the second pass work from the
38
+ * first pass's pruned leftovers.
39
+ */
40
+ private _buildFinalReportLines;
35
41
  private _computeCompletedSets;
36
42
  private _removeIncompleteTestArtifacts;
37
43
  private _patchProjectSuites;
@@ -1 +1 @@
1
- {"version":3,"file":"incremental-blob-reporter.d.ts","sourceRoot":"","sources":["../../src/reporter/incremental-blob-reporter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,UAAU,EACV,UAAU,EACV,QAAQ,EACR,KAAK,EACL,QAAQ,EACR,UAAU,EACV,QAAQ,EACT,MAAM,2BAA2B,CAAC;AAwBnC;;;;;;;GAOG;AACH,MAAM,MAAM,gBAAgB,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAEnD,cAAM,uBAAwB,YAAW,QAAQ;IAC/C,OAAO,CAAC,kBAAkB,CAAyB;IACnD,OAAO,CAAC,WAAW,CAIjB;IACF,OAAO,CAAC,UAAU,CAGhB;IACF,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,YAAY,CAAgB;IACpC,OAAO,CAAC,iBAAiB,CAA+B;IACxD,OAAO,CAAC,YAAY,CAAkC;IACtD,OAAO,CAAC,UAAU,CAAoC;IACtD,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,iBAAiB,CAA0B;IACnD,OAAO,CAAC,mBAAmB,CAA0B;IACrD,OAAO,CAAC,YAAY,CAAkC;IACtD,OAAO,CAAC,YAAY,CAGN;IACd,OAAO,CAAC,UAAU,CAAsB;IACxC,OAAO,CAAC,gBAAgB,CAA8B;IAEtD,cAWC;IAED,OAAO,CAAC,cAAc,CAA6B;IAEnD,OAAO,CAAC,mBAAmB;IA+B3B,OAAO,CAAC,oBAAoB;IAO5B,OAAO,CAAC,qBAAqB;YASf,uBAAuB;IAqErC,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,qBAAqB;IAY7B,OAAO,CAAC,8BAA8B;IAWtC,OAAO,CAAC,mBAAmB;IAoB3B,OAAO,KAAK,QAAQ,GAKnB;IAED,OAAO,KAAK,UAAU,GAErB;IAED,OAAO,KAAK,aAAa,GAExB;IAED,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,aAAa;YAUP,SAAS;IAwBvB,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI,CAc9C;IAED,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI,CAEpD;IAED,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI,CAuBlD;IAED,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAUpE;IAED,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAUlE;IAEK,KAAK,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAsB7C;IAED;;OAEG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAE3D;IAED,aAAa,IAAI,OAAO,CAEvB;CACF;AAID,wBAAgB,0BAA0B,IAAI,uBAAuB,GAAG,IAAI,CAE3E;AAED,wBAAgB,kCAAkC,CAChD,QAAQ,EAAE,uBAAuB,GAChC,IAAI,CAEN;eAEc,uBAAuB"}
1
+ {"version":3,"file":"incremental-blob-reporter.d.ts","sourceRoot":"","sources":["../../src/reporter/incremental-blob-reporter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,UAAU,EACV,UAAU,EACV,QAAQ,EACR,KAAK,EACL,QAAQ,EACR,UAAU,EACV,QAAQ,EACT,MAAM,2BAA2B,CAAC;AAwBnC;;;;;;;GAOG;AACH,MAAM,MAAM,gBAAgB,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AASnD,cAAM,uBAAwB,YAAW,QAAQ;IAC/C,OAAO,CAAC,kBAAkB,CAAyB;IACnD,OAAO,CAAC,WAAW,CAIjB;IACF,OAAO,CAAC,UAAU,CAGhB;IACF,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,YAAY,CAAgB;IACpC,OAAO,CAAC,iBAAiB,CAA+B;IACxD,OAAO,CAAC,YAAY,CAAkC;IACtD,OAAO,CAAC,UAAU,CAAoC;IACtD,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,iBAAiB,CAA0B;IACnD,OAAO,CAAC,mBAAmB,CAA0B;IACrD,OAAO,CAAC,YAAY,CAAkC;IACtD,OAAO,CAAC,YAAY,CAAsD;IAC1E,OAAO,CAAC,UAAU,CAAsB;IACxC,OAAO,CAAC,gBAAgB,CAA8B;IAEtD,cAWC;IAED,OAAO,CAAC,cAAc,CAA6B;IAEnD,OAAO,CAAC,mBAAmB;IA+B3B,OAAO,CAAC,oBAAoB;IAO5B,OAAO,CAAC,qBAAqB;YASf,uBAAuB;IAqErC;;;;;OAKG;IACH,OAAO,CAAC,sBAAsB;IAuB9B,OAAO,CAAC,qBAAqB;IA0B7B,OAAO,CAAC,8BAA8B;IAWtC,OAAO,CAAC,mBAAmB;IAmB3B,OAAO,KAAK,QAAQ,GAKnB;IAED,OAAO,KAAK,UAAU,GAErB;IAED,OAAO,KAAK,aAAa,GAExB;IAED,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,aAAa;YAUP,SAAS;IAwBvB,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI,CAc9C;IAED,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI,CAEpD;IAED,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI,CA0BlD;IAED,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAUpE;IAED,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAUlE;IAEK,KAAK,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAiC7C;IAED;;OAEG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAE3D;IAED,aAAa,IAAI,OAAO,CAEvB;CACF;AAID,wBAAgB,0BAA0B,IAAI,uBAAuB,GAAG,IAAI,CAE3E;AAED,wBAAgB,kCAAkC,CAChD,QAAQ,EAAE,uBAAuB,GAChC,IAAI,CAEN;eAEc,uBAAuB"}
@@ -28,7 +28,7 @@ class IncrementalBlobReporter {
28
28
  _completedTestIds = new Set();
29
29
  _completedResultIds = new Set();
30
30
  _testRetries = new Map(); // testId → configured retries
31
- _testResults = new Map(); // testId → retryIndex → {resultId, status, expectedStatus}
31
+ _testResults = new Map(); // testId → retryIndex → RecordedRetry
32
32
  _startTime = Date.now();
33
33
  _finalizePromise = null;
34
34
  constructor() {
@@ -101,8 +101,8 @@ class IncrementalBlobReporter {
101
101
  if (this._uploader) {
102
102
  await this._uploader.waitForUploads();
103
103
  }
104
- this._finalizeReport(result, pruneIncompleteTests);
105
- await this._writeZip();
104
+ const lines = this._buildFinalReportLines(result, pruneIncompleteTests);
105
+ await this._writeZip(lines);
106
106
  if (!upload) {
107
107
  logFinalizeComplete();
108
108
  return;
@@ -120,32 +120,49 @@ class IncrementalBlobReporter {
120
120
  }
121
121
  logFinalizeComplete();
122
122
  }
123
- _finalizeReport(result, pruneIncompleteTests) {
123
+ /**
124
+ * Computes the lines to write, without touching `_reportLines`: recording
125
+ * continues after the SIGINT salvage, and the onEnd pass re-finalizes from
126
+ * the full stream — mutating here would make the second pass work from the
127
+ * first pass's pruned leftovers.
128
+ */
129
+ _buildFinalReportLines(result, pruneIncompleteTests) {
130
+ let lines = [...this._reportLines];
124
131
  if (pruneIncompleteTests) {
125
132
  // Determine which tests are fully complete (passed or all retries finished)
126
133
  this._computeCompletedSets();
127
134
  // Remove unrun tests from onProject suites
128
- this._patchProjectSuites();
135
+ lines = this._patchProjectSuites(lines);
129
136
  // Remove events (onTestBegin, onStepBegin, etc.) for incomplete tests
130
- this._removeIncompleteTestArtifacts();
137
+ lines = this._removeIncompleteTestArtifacts(lines);
131
138
  }
132
- // Push onEnd directly. SIGINT marks the reporter finalized before this,
133
- // so using _appendEvent would drop the synthetic onEnd event.
134
- this._reportLines.push(JSON.stringify((0, lifecycle_events_1.buildOnEndEvent)(result)));
139
+ // The onEnd event belongs only in the written report, never in the
140
+ // in-memory stream — a later finalize pass appends its own.
141
+ lines.push(JSON.stringify((0, lifecycle_events_1.buildOnEndEvent)(result)));
142
+ return lines;
135
143
  }
136
144
  _computeCompletedSets() {
137
145
  for (const [testId, results] of this._testResults) {
138
146
  const maxRetries = this._testRetries.get(testId);
139
- if ((0, reporter_1.areRetriesComplete)(maxRetries, results)) {
147
+ // Shutdown artifacts are not finished retries: an "interrupted" result
148
+ // is a test cut off mid-flight, and a post-signal "skipped" is a serial
149
+ // sibling Playwright marks off while shutting down. The resume shard
150
+ // re-runs both, so counting them as complete would put their results in
151
+ // the final zip alongside the resume shard's, duplicating tests in the
152
+ // merge. A post-signal passed/failed is real, though: the test finished
153
+ // its body and only its aftermath overlapped the shutdown.
154
+ const finished = new Map([...results].filter(([, r]) => r.status !== "interrupted" &&
155
+ !(r.postSignal && r.status === "skipped")));
156
+ if ((0, reporter_1.areRetriesComplete)(maxRetries, finished)) {
140
157
  this._completedTestIds.add(testId);
141
- for (const { resultId } of results.values()) {
158
+ for (const { resultId } of finished.values()) {
142
159
  this._completedResultIds.add(resultId);
143
160
  }
144
161
  }
145
162
  }
146
163
  }
147
- _removeIncompleteTestArtifacts() {
148
- this._reportLines = this._reportLines.filter((line) => {
164
+ _removeIncompleteTestArtifacts(lines) {
165
+ return lines.filter((line) => {
149
166
  const parsed = JSON.parse(line);
150
167
  const resultId = parsed.params?.resultId ?? parsed.params?.result?.id;
151
168
  if (!resultId) {
@@ -154,20 +171,19 @@ class IncrementalBlobReporter {
154
171
  return this._completedResultIds.has(resultId);
155
172
  });
156
173
  }
157
- _patchProjectSuites() {
158
- for (let i = 0; i < this._reportLines.length; i++) {
159
- const line = this._reportLines[i];
174
+ _patchProjectSuites(lines) {
175
+ return lines.map((line) => {
160
176
  const parsed = JSON.parse(line);
161
177
  if (parsed.method !== "onProject") {
162
- continue;
178
+ return line;
163
179
  }
164
180
  const project = parsed.params.project;
165
181
  const filteredSuites = project.suites
166
182
  .map((suite) => (0, ibr_utils_1.filterSuiteByCompletedTests)(suite, this._completedTestIds))
167
183
  .filter(Boolean);
168
184
  project.suites = filteredSuites;
169
- this._reportLines[i] = JSON.stringify(parsed);
170
- }
185
+ return JSON.stringify(parsed);
186
+ });
171
187
  }
172
188
  get _zipPath() {
173
189
  return path_1.default.join(this._outputDir, `incremental-report-${this._shardIndex}.zip`);
@@ -183,9 +199,10 @@ class IncrementalBlobReporter {
183
199
  fs_1.default.mkdirSync(this._outputDir, { recursive: true });
184
200
  }
185
201
  _appendEvent(event) {
186
- if ((0, reporter_state_1.isFinalized)()) {
187
- return false;
188
- }
202
+ // Deliberately not gated on isFinalized(): the SIGINT salvage seals a
203
+ // *snapshot*, not the stream. Playwright keeps running afterEach/afterAll
204
+ // teardown after the signal, and those events feed the second finalize
205
+ // pass in onEnd.
189
206
  this._reportLines.push(JSON.stringify(event));
190
207
  return true;
191
208
  }
@@ -198,10 +215,10 @@ class IncrementalBlobReporter {
198
215
  }
199
216
  return allAppended;
200
217
  }
201
- async _writeZip() {
218
+ async _writeZip(lines) {
202
219
  this._ensureDirs();
203
220
  // Write report.jsonl to the output directory
204
- const jsonlContent = this._reportLines.join("\n") + "\n";
221
+ const jsonlContent = lines.join("\n") + "\n";
205
222
  fs_1.default.writeFileSync(this._jsonlPath, jsonlContent, "utf8");
206
223
  // Write _empirical_urls.json with attachment URL mappings
207
224
  const urlsJson = (0, ibr_utils_1.buildUrlsJson)(this._attachmentUrlMap);
@@ -209,7 +226,7 @@ class IncrementalBlobReporter {
209
226
  // Create zip from the staging directory
210
227
  const zipBuffer = await (0, zip_1.createZipFromDirectory)(this._stagingDir);
211
228
  fs_1.default.writeFileSync(this._zipPath, zipBuffer);
212
- console.log(`[IncrementalBlobReporter] Zip updated: ${this._zipPath} (${this._reportLines.length} events, ${this._attachmentUrlMap.size} attachments)`);
229
+ console.log(`[IncrementalBlobReporter] Zip updated: ${this._zipPath} (${lines.length} events, ${this._attachmentUrlMap.size} attachments)`);
213
230
  }
214
231
  onBegin(config, suite) {
215
232
  this._ensureDirs();
@@ -234,6 +251,9 @@ class IncrementalBlobReporter {
234
251
  resultId,
235
252
  status: result.status,
236
253
  expectedStatus: test.expectedStatus,
254
+ // The SIGINT salvage has started; this result arrived during
255
+ // interruption shutdown rather than the normal run.
256
+ postSignal: this._finalizePromise !== null,
237
257
  });
238
258
  }
239
259
  }
@@ -246,9 +266,19 @@ class IncrementalBlobReporter {
246
266
  }
247
267
  async onEnd(result) {
248
268
  this._removeSignalHandler();
249
- // If SIGINT already started the partial-report finalization, let that
250
- // finish instead of appending a second onEnd or racing another zip write.
269
+ // Second phase of an interrupted run: the SIGINT pass salvaged a snapshot
270
+ // within milliseconds (SIGKILL insurance); by the time Playwright calls
271
+ // onEnd the teardown chain has finished, so re-finalize from the full
272
+ // stream and overwrite the salvage blob with the post-teardown report.
251
273
  if (this._finalizePromise) {
274
+ await this._finalizePromise;
275
+ this._finalizePromise = this._finalizeAndMaybeUpload({
276
+ result,
277
+ source: "onEnd",
278
+ markGlobalFinalized: false,
279
+ pruneIncompleteTests: true,
280
+ upload: true,
281
+ });
252
282
  await this._finalizePromise;
253
283
  return;
254
284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empiricalrun/playwright-utils",
3
- "version": "0.58.1",
3
+ "version": "0.58.2",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/",
6
6
  "access": "public"
@@ -30,16 +30,16 @@
30
30
  },
31
31
  "author": "Empirical Team <hey@empirical.run>",
32
32
  "devDependencies": {
33
- "@playwright/test": "1.60.0",
33
+ "@playwright/test": "1.62.1",
34
34
  "@types/async-retry": "^1.4.8",
35
35
  "@types/authenticator": "^1.1.4",
36
36
  "@types/babel__code-frame": "^7.0.6",
37
37
  "@types/console-log-level": "^1.4.5",
38
38
  "@types/mime": "3.0.0",
39
- "@types/node": "^20.14.9",
39
+ "@types/node": "^22",
40
40
  "@types/serve-handler": "^6.1.4",
41
41
  "openapi-typescript": "7.6.1",
42
- "playwright-core": "1.60.0",
42
+ "playwright-core": "1.62.1",
43
43
  "serve-handler": "^6.1.6",
44
44
  "@empiricalrun/shared-types": "0.15.3"
45
45
  },