@empiricalrun/playwright-utils 0.58.1 → 0.58.3

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.
@@ -28,9 +28,10 @@ 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
+ _rootDir = process.cwd();
34
35
  constructor() {
35
36
  if (process.env.SHARD_INDEX && process.env.TOTAL_SHARDS) {
36
37
  this._shardIndex = Number.parseInt(process.env.SHARD_INDEX, 10);
@@ -101,51 +102,73 @@ class IncrementalBlobReporter {
101
102
  if (this._uploader) {
102
103
  await this._uploader.waitForUploads();
103
104
  }
104
- this._finalizeReport(result, pruneIncompleteTests);
105
- await this._writeZip();
105
+ const lines = this._buildFinalReportLines(result, pruneIncompleteTests);
106
+ await this._writeZip(lines);
106
107
  if (!upload) {
107
108
  logFinalizeComplete();
108
109
  return;
109
110
  }
110
111
  if (!this._uploader) {
111
- console.warn("[IncrementalBlobReporterFailure] No uploader available, skipping upload");
112
+ console.warn("[IncrementalBlobReporter] No uploader available, skipping upload");
112
113
  logFinalizeComplete();
113
114
  return;
114
115
  }
115
116
  await this._uploader.uploadFile(this._zipPath, `blobs/incremental-report-${this._shardIndex}.zip`);
116
117
  await this._uploader.waitForUploads();
118
+ if (source === "onEnd") {
119
+ fs_1.default.writeFileSync(this._markerPath, JSON.stringify({ status: result.status, events: lines.length }), "utf8");
120
+ await this._uploader.uploadFile(this._markerPath, `blobs/incremental-report-${this._shardIndex}.zip.complete`);
121
+ await this._uploader.waitForUploads();
122
+ }
117
123
  console.log(`[IncrementalBlobReporter] Finalize and upload complete from ${source}`);
118
124
  if ((0, ibr_utils_1.isLocalTesting)()) {
119
125
  await (0, local_test_1.mergeForLocalTest)(this._currentWorkingDir, this._outputDir);
120
126
  }
121
127
  logFinalizeComplete();
122
128
  }
123
- _finalizeReport(result, pruneIncompleteTests) {
129
+ /**
130
+ * Computes the lines to write, without touching `_reportLines`: recording
131
+ * continues after the SIGINT salvage, and the onEnd pass re-finalizes from
132
+ * the full stream — mutating here would make the second pass work from the
133
+ * first pass's pruned leftovers.
134
+ */
135
+ _buildFinalReportLines(result, pruneIncompleteTests) {
136
+ let lines = [...this._reportLines];
124
137
  if (pruneIncompleteTests) {
125
138
  // Determine which tests are fully complete (passed or all retries finished)
126
139
  this._computeCompletedSets();
127
140
  // Remove unrun tests from onProject suites
128
- this._patchProjectSuites();
141
+ lines = this._patchProjectSuites(lines);
129
142
  // Remove events (onTestBegin, onStepBegin, etc.) for incomplete tests
130
- this._removeIncompleteTestArtifacts();
143
+ lines = this._removeIncompleteTestArtifacts(lines);
131
144
  }
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)));
145
+ // The onEnd event belongs only in the written report, never in the
146
+ // in-memory stream — a later finalize pass appends its own.
147
+ lines.push(JSON.stringify((0, lifecycle_events_1.buildOnEndEvent)(result)));
148
+ return lines;
135
149
  }
136
150
  _computeCompletedSets() {
137
151
  for (const [testId, results] of this._testResults) {
138
152
  const maxRetries = this._testRetries.get(testId);
139
- if ((0, reporter_1.areRetriesComplete)(maxRetries, results)) {
153
+ // Shutdown artifacts are not finished retries: an "interrupted" result
154
+ // is a test cut off mid-flight, and a post-signal "skipped" is a serial
155
+ // sibling Playwright marks off while shutting down. The resume shard
156
+ // re-runs both, so counting them as complete would put their results in
157
+ // the final zip alongside the resume shard's, duplicating tests in the
158
+ // merge. A post-signal passed/failed is real, though: the test finished
159
+ // its body and only its aftermath overlapped the shutdown.
160
+ const finished = new Map([...results].filter(([, r]) => r.status !== "interrupted" &&
161
+ !(r.postSignal && r.status === "skipped")));
162
+ if ((0, reporter_1.areRetriesComplete)(maxRetries, finished)) {
140
163
  this._completedTestIds.add(testId);
141
- for (const { resultId } of results.values()) {
164
+ for (const { resultId } of finished.values()) {
142
165
  this._completedResultIds.add(resultId);
143
166
  }
144
167
  }
145
168
  }
146
169
  }
147
- _removeIncompleteTestArtifacts() {
148
- this._reportLines = this._reportLines.filter((line) => {
170
+ _removeIncompleteTestArtifacts(lines) {
171
+ return lines.filter((line) => {
149
172
  const parsed = JSON.parse(line);
150
173
  const resultId = parsed.params?.resultId ?? parsed.params?.result?.id;
151
174
  if (!resultId) {
@@ -154,24 +177,26 @@ class IncrementalBlobReporter {
154
177
  return this._completedResultIds.has(resultId);
155
178
  });
156
179
  }
157
- _patchProjectSuites() {
158
- for (let i = 0; i < this._reportLines.length; i++) {
159
- const line = this._reportLines[i];
180
+ _patchProjectSuites(lines) {
181
+ return lines.map((line) => {
160
182
  const parsed = JSON.parse(line);
161
183
  if (parsed.method !== "onProject") {
162
- continue;
184
+ return line;
163
185
  }
164
186
  const project = parsed.params.project;
165
187
  const filteredSuites = project.suites
166
188
  .map((suite) => (0, ibr_utils_1.filterSuiteByCompletedTests)(suite, this._completedTestIds))
167
189
  .filter(Boolean);
168
190
  project.suites = filteredSuites;
169
- this._reportLines[i] = JSON.stringify(parsed);
170
- }
191
+ return JSON.stringify(parsed);
192
+ });
171
193
  }
172
194
  get _zipPath() {
173
195
  return path_1.default.join(this._outputDir, `incremental-report-${this._shardIndex}.zip`);
174
196
  }
197
+ get _markerPath() {
198
+ return `${this._zipPath}.complete`;
199
+ }
175
200
  get _jsonlPath() {
176
201
  return path_1.default.join(this._stagingDir, "report.jsonl");
177
202
  }
@@ -183,9 +208,10 @@ class IncrementalBlobReporter {
183
208
  fs_1.default.mkdirSync(this._outputDir, { recursive: true });
184
209
  }
185
210
  _appendEvent(event) {
186
- if ((0, reporter_state_1.isFinalized)()) {
187
- return false;
188
- }
211
+ // Deliberately not gated on isFinalized(): the SIGINT salvage seals a
212
+ // *snapshot*, not the stream. Playwright keeps running afterEach/afterAll
213
+ // teardown after the signal, and those events feed the second finalize
214
+ // pass in onEnd.
189
215
  this._reportLines.push(JSON.stringify(event));
190
216
  return true;
191
217
  }
@@ -198,10 +224,10 @@ class IncrementalBlobReporter {
198
224
  }
199
225
  return allAppended;
200
226
  }
201
- async _writeZip() {
227
+ async _writeZip(lines) {
202
228
  this._ensureDirs();
203
229
  // Write report.jsonl to the output directory
204
- const jsonlContent = this._reportLines.join("\n") + "\n";
230
+ const jsonlContent = lines.join("\n") + "\n";
205
231
  fs_1.default.writeFileSync(this._jsonlPath, jsonlContent, "utf8");
206
232
  // Write _empirical_urls.json with attachment URL mappings
207
233
  const urlsJson = (0, ibr_utils_1.buildUrlsJson)(this._attachmentUrlMap);
@@ -209,10 +235,18 @@ class IncrementalBlobReporter {
209
235
  // Create zip from the staging directory
210
236
  const zipBuffer = await (0, zip_1.createZipFromDirectory)(this._stagingDir);
211
237
  fs_1.default.writeFileSync(this._zipPath, zipBuffer);
212
- console.log(`[IncrementalBlobReporter] Zip updated: ${this._zipPath} (${this._reportLines.length} events, ${this._attachmentUrlMap.size} attachments)`);
238
+ console.log(`[IncrementalBlobReporter] Zip updated: ${this._zipPath} (${lines.length} events, ${this._attachmentUrlMap.size} attachments)`);
213
239
  }
214
240
  onBegin(config, suite) {
215
241
  this._ensureDirs();
242
+ this._rootDir = config.rootDir;
243
+ // The env vars win (production sets them), but a sharded run without
244
+ // them should still stamp the right shard into the blob metadata and
245
+ // the zip name instead of defaulting every shard to 1/1.
246
+ if (!process.env.SHARD_INDEX && config.shard) {
247
+ this._shardIndex = config.shard.current;
248
+ this._totalShards = config.shard.total;
249
+ }
216
250
  this._appendEvents((0, lifecycle_events_1.buildOnBeginEvents)(config, suite, this._currentWorkingDir, this._shardIndex, this._totalShards));
217
251
  console.log(`[IncrementalBlobReporter] Started with ${suite.allTests().length} tests`);
218
252
  }
@@ -220,7 +254,7 @@ class IncrementalBlobReporter {
220
254
  this._appendEvent((0, lifecycle_events_1.buildOnTestBeginEvent)(test, result, this._resultIdMap));
221
255
  }
222
256
  onTestEnd(test, result) {
223
- const events = (0, lifecycle_events_1.buildOnTestEndEvents)(test, result, this._resultIdMap, this._stagingDir);
257
+ const events = (0, lifecycle_events_1.buildOnTestEndEvents)(test, result, this._resultIdMap, this._stagingDir, this._rootDir);
224
258
  if (this._appendEvents(events)) {
225
259
  if (!this._testRetries.has(test.id)) {
226
260
  this._testRetries.set(test.id, test.retries);
@@ -234,21 +268,43 @@ class IncrementalBlobReporter {
234
268
  resultId,
235
269
  status: result.status,
236
270
  expectedStatus: test.expectedStatus,
271
+ // The SIGINT salvage has started; this result arrived during
272
+ // interruption shutdown rather than the normal run.
273
+ postSignal: this._finalizePromise !== null,
237
274
  });
238
275
  }
239
276
  }
240
277
  }
278
+ onStdOut(chunk, test, result) {
279
+ this._appendEvent((0, lifecycle_events_1.buildOnStdIOEvent)("stdout", chunk, test, result, this._resultIdMap));
280
+ }
281
+ onStdErr(chunk, test, result) {
282
+ this._appendEvent((0, lifecycle_events_1.buildOnStdIOEvent)("stderr", chunk, test, result, this._resultIdMap));
283
+ }
284
+ onError(error, workerInfo) {
285
+ this._appendEvent((0, lifecycle_events_1.buildOnErrorEvent)(error, workerInfo));
286
+ }
241
287
  onStepBegin(test, result, step) {
242
288
  this._appendEvent((0, lifecycle_events_1.buildOnStepBeginEvent)(test, result, step, this._resultIdMap, this._stepIdMap));
243
289
  }
244
290
  onStepEnd(test, result, step) {
245
- this._appendEvent((0, lifecycle_events_1.buildOnStepEndEvent)(test, result, step, this._resultIdMap, this._stepIdMap));
291
+ this._appendEvent((0, lifecycle_events_1.buildOnStepEndEvent)(test, result, step, this._resultIdMap, this._stepIdMap, this._rootDir));
246
292
  }
247
293
  async onEnd(result) {
248
294
  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.
295
+ // Second phase of an interrupted run: the SIGINT pass salvaged a snapshot
296
+ // within milliseconds (SIGKILL insurance); by the time Playwright calls
297
+ // onEnd the teardown chain has finished, so re-finalize from the full
298
+ // stream and overwrite the salvage blob with the post-teardown report.
251
299
  if (this._finalizePromise) {
300
+ await this._finalizePromise;
301
+ this._finalizePromise = this._finalizeAndMaybeUpload({
302
+ result,
303
+ source: "onEnd",
304
+ markGlobalFinalized: false,
305
+ pruneIncompleteTests: true,
306
+ upload: true,
307
+ });
252
308
  await this._finalizePromise;
253
309
  return;
254
310
  }
@@ -257,9 +313,7 @@ class IncrementalBlobReporter {
257
313
  source: "onEnd",
258
314
  markGlobalFinalized: false,
259
315
  pruneIncompleteTests: false,
260
- // Normal completion already has Playwright's blob/html reporters.
261
- // Only the SIGINT path uploads this incremental fallback blob.
262
- upload: false,
316
+ upload: true,
263
317
  });
264
318
  await this._finalizePromise;
265
319
  console.log(`[IncrementalBlobReporter] Finished with status: ${result.status}`);
@@ -1,4 +1,4 @@
1
- import type { FullConfig, FullProject, FullResult, Suite, TestCase, TestResult, TestStep } from "@playwright/test/reporter";
1
+ import type { FullConfig, FullProject, FullResult, Suite, TestCase, TestError, TestResult, TestStep, WorkerInfo } from "@playwright/test/reporter";
2
2
  export type JsonEvent = {
3
3
  method: string;
4
4
  params?: object;
@@ -6,8 +6,10 @@ export type JsonEvent = {
6
6
  export declare function buildOnBeginEvents(config: FullConfig, suite: Suite, currentWorkingDir: string, shardIndex: number, totalShards: number): JsonEvent[];
7
7
  export declare function buildOnProjectEvent(config: FullConfig, project: FullProject, projectSuite: Suite, currentWorkingDir: string): JsonEvent;
8
8
  export declare function buildOnTestBeginEvent(test: TestCase, result: TestResult, resultIdMap: Map<string, string>): JsonEvent;
9
- export declare function buildOnTestEndEvents(test: TestCase, result: TestResult, resultIdMap: Map<string, string>, stagingDir: string): JsonEvent[];
9
+ export declare function buildOnTestEndEvents(test: TestCase, result: TestResult, resultIdMap: Map<string, string>, stagingDir: string, rootDir: string): JsonEvent[];
10
+ export declare function buildOnStdIOEvent(type: "stdout" | "stderr", chunk: string | Buffer, test: TestCase | undefined, result: TestResult | undefined, resultIdMap: Map<string, string>): JsonEvent;
11
+ export declare function buildOnErrorEvent(error: TestError, workerInfo?: WorkerInfo): JsonEvent;
10
12
  export declare function buildOnStepBeginEvent(test: TestCase, result: TestResult, step: TestStep, resultIdMap: Map<string, string>, stepIdMap: Map<TestStep, string>): JsonEvent;
11
- export declare function buildOnStepEndEvent(test: TestCase, result: TestResult, step: TestStep, resultIdMap: Map<string, string>, stepIdMap: Map<TestStep, string>): JsonEvent;
13
+ export declare function buildOnStepEndEvent(test: TestCase, result: TestResult, step: TestStep, resultIdMap: Map<string, string>, stepIdMap: Map<TestStep, string>, rootDir: string): JsonEvent;
12
14
  export declare function buildOnEndEvent(result: FullResult): JsonEvent;
13
15
  //# sourceMappingURL=lifecycle-events.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle-events.d.ts","sourceRoot":"","sources":["../../src/reporter/lifecycle-events.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EACV,WAAW,EACX,UAAU,EACV,KAAK,EACL,QAAQ,EACR,UAAU,EACV,QAAQ,EACT,MAAM,2BAA2B,CAAC;AAgBnC,MAAM,MAAM,SAAS,GAAG;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,KAAK,EACZ,iBAAiB,EAAE,MAAM,EACzB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,GAClB,SAAS,EAAE,CAqDb;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,WAAW,EACpB,YAAY,EAAE,KAAK,EACnB,iBAAiB,EAAE,MAAM,GACxB,SAAS,CA+BX;AAED,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,UAAU,EAClB,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC/B,SAAS,CAeX;AAED,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,UAAU,EAClB,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,UAAU,EAAE,MAAM,GACjB,SAAS,EAAE,CAqDb;AAED,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,UAAU,EAClB,IAAI,EAAE,QAAQ,EACd,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,SAAS,EAAE,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,GAC/B,SAAS,CAsBX;AAED,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,UAAU,EAClB,IAAI,EAAE,QAAQ,EACd,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,SAAS,EAAE,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,GAC/B,SAAS,CAuBX;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAW7D"}
1
+ {"version":3,"file":"lifecycle-events.d.ts","sourceRoot":"","sources":["../../src/reporter/lifecycle-events.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EACV,WAAW,EACX,UAAU,EACV,KAAK,EACL,QAAQ,EACR,SAAS,EACT,UAAU,EACV,QAAQ,EACR,UAAU,EACX,MAAM,2BAA2B,CAAC;AAiBnC,MAAM,MAAM,SAAS,GAAG;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,KAAK,EACZ,iBAAiB,EAAE,MAAM,EACzB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,GAClB,SAAS,EAAE,CAqDb;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,WAAW,EACpB,YAAY,EAAE,KAAK,EACnB,iBAAiB,EAAE,MAAM,GACxB,SAAS,CA+BX;AAED,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,UAAU,EAClB,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC/B,SAAS,CAeX;AAED,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,UAAU,EAClB,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,SAAS,EAAE,CA+Db;AAED,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,QAAQ,GAAG,QAAQ,EACzB,KAAK,EAAE,MAAM,GAAG,MAAM,EACtB,IAAI,EAAE,QAAQ,GAAG,SAAS,EAC1B,MAAM,EAAE,UAAU,GAAG,SAAS,EAC9B,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC/B,SAAS,CAgBX;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,SAAS,EAChB,UAAU,CAAC,EAAE,UAAU,GACtB,SAAS,CAcX;AAED,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,UAAU,EAClB,IAAI,EAAE,QAAQ,EACd,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,SAAS,EAAE,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,GAC/B,SAAS,CAsBX;AAED,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,UAAU,EAClB,IAAI,EAAE,QAAQ,EACd,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,SAAS,EAAE,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,EAChC,OAAO,EAAE,MAAM,GACd,SAAS,CA+BX;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAW7D"}
@@ -7,6 +7,8 @@ exports.buildOnBeginEvents = buildOnBeginEvents;
7
7
  exports.buildOnProjectEvent = buildOnProjectEvent;
8
8
  exports.buildOnTestBeginEvent = buildOnTestBeginEvent;
9
9
  exports.buildOnTestEndEvents = buildOnTestEndEvents;
10
+ exports.buildOnStdIOEvent = buildOnStdIOEvent;
11
+ exports.buildOnErrorEvent = buildOnErrorEvent;
10
12
  exports.buildOnStepBeginEvent = buildOnStepBeginEvent;
11
13
  exports.buildOnStepEndEvent = buildOnStepEndEvent;
12
14
  exports.buildOnEndEvent = buildOnEndEvent;
@@ -108,7 +110,7 @@ function buildOnTestBeginEvent(test, result, resultIdMap) {
108
110
  },
109
111
  };
110
112
  }
111
- function buildOnTestEndEvents(test, result, resultIdMap, stagingDir) {
113
+ function buildOnTestEndEvents(test, result, resultIdMap, stagingDir, rootDir) {
112
114
  const resultId = (0, ibr_utils_1.getOrCreateId)(resultIdMap, `${test.id}-${result.retry}`);
113
115
  const events = [];
114
116
  // onAttach for each attachment (matching Playwright's blob format)
@@ -131,6 +133,11 @@ function buildOnTestEndEvents(test, result, resultIdMap, stagingDir) {
131
133
  name: attachment.name,
132
134
  contentType: attachment.contentType,
133
135
  path: attachmentPath,
136
+ // Body-based attachments travel inline, mirroring
137
+ // TeleReporterEmitter._serializeAttachments.
138
+ base64: attachment.body
139
+ ? attachment.body.toString("base64")
140
+ : undefined,
134
141
  },
135
142
  ],
136
143
  },
@@ -143,7 +150,7 @@ function buildOnTestEndEvents(test, result, resultIdMap, stagingDir) {
143
150
  testId: test.id,
144
151
  expectedStatus: test.expectedStatus,
145
152
  timeout: test.timeout,
146
- annotations: test.annotations,
153
+ annotations: (0, ibr_utils_1.relativeAnnotationLocations)(test.annotations, rootDir),
147
154
  },
148
155
  result: {
149
156
  id: resultId,
@@ -155,11 +162,47 @@ function buildOnTestEndEvents(test, result, resultIdMap, stagingDir) {
155
162
  location: e.location,
156
163
  snippet: e.snippet,
157
164
  })),
165
+ // Blob v2 carries annotations on the result; the merge reads them
166
+ // from here (result-level), not from the test payload above.
167
+ annotations: result.annotations?.length
168
+ ? (0, ibr_utils_1.relativeAnnotationLocations)(result.annotations, rootDir)
169
+ : undefined,
158
170
  },
159
171
  },
160
172
  });
161
173
  return events;
162
174
  }
175
+ function buildOnStdIOEvent(type, chunk, test, result, resultIdMap) {
176
+ const isBase64 = typeof chunk !== "string";
177
+ const data = isBase64 ? chunk.toString("base64") : chunk;
178
+ return {
179
+ method: "onStdIO",
180
+ params: {
181
+ testId: test?.id,
182
+ resultId: test && result
183
+ ? (0, ibr_utils_1.getOrCreateId)(resultIdMap, `${test.id}-${result.retry}`)
184
+ : undefined,
185
+ type,
186
+ data,
187
+ isBase64,
188
+ },
189
+ };
190
+ }
191
+ function buildOnErrorEvent(error, workerInfo) {
192
+ return {
193
+ method: "onError",
194
+ params: {
195
+ error,
196
+ workerInfo: workerInfo
197
+ ? {
198
+ workerIndex: workerInfo.workerIndex,
199
+ parallelIndex: workerInfo.parallelIndex,
200
+ projectName: workerInfo.project.name,
201
+ }
202
+ : undefined,
203
+ },
204
+ };
205
+ }
163
206
  function buildOnStepBeginEvent(test, result, step, resultIdMap, stepIdMap) {
164
207
  const resultId = (0, ibr_utils_1.getOrCreateId)(resultIdMap, `${test.id}-${result.retry}`);
165
208
  const stepId = (0, ibr_utils_1.getOrCreateStepId)(stepIdMap, step);
@@ -182,7 +225,7 @@ function buildOnStepBeginEvent(test, result, step, resultIdMap, stepIdMap) {
182
225
  },
183
226
  };
184
227
  }
185
- function buildOnStepEndEvent(test, result, step, resultIdMap, stepIdMap) {
228
+ function buildOnStepEndEvent(test, result, step, resultIdMap, stepIdMap, rootDir) {
186
229
  const resultId = (0, ibr_utils_1.getOrCreateId)(resultIdMap, `${test.id}-${result.retry}`);
187
230
  const stepId = (0, ibr_utils_1.getOrCreateStepId)(stepIdMap, step);
188
231
  return {
@@ -201,6 +244,14 @@ function buildOnStepEndEvent(test, result, step, resultIdMap, stepIdMap) {
201
244
  snippet: step.error.snippet,
202
245
  }
203
246
  : undefined,
247
+ // Indices into the result's attachments, matching
248
+ // TeleReporterEmitter._serializeStepEnd.
249
+ attachments: step.attachments.length
250
+ ? step.attachments.map((a) => result.attachments.indexOf(a))
251
+ : undefined,
252
+ annotations: step.annotations.length
253
+ ? (0, ibr_utils_1.relativeAnnotationLocations)(step.annotations, rootDir)
254
+ : undefined,
204
255
  },
205
256
  },
206
257
  };
@@ -0,0 +1,63 @@
1
+ import type { BlobEvent } from "./harness";
2
+ export interface NormalizedAttachment {
3
+ name: string;
4
+ contentType: string;
5
+ /**
6
+ * Whether the merged report still points at real content (path or inline
7
+ * body). Compared only where a scenario opts in: the empirical pipeline
8
+ * resolves attachment bytes through _empirical_urls.json, so name+type is
9
+ * the baseline parity contract.
10
+ */
11
+ hasContent: boolean;
12
+ }
13
+ export interface NormalizedResult {
14
+ retry: number;
15
+ status: string;
16
+ errorMessages: string[];
17
+ stdout: string[];
18
+ stderr: string[];
19
+ attachments: NormalizedAttachment[];
20
+ }
21
+ export interface NormalizedTest {
22
+ projectName: string;
23
+ expectedStatus: string;
24
+ status: string;
25
+ annotations: {
26
+ type: string;
27
+ description?: string;
28
+ }[];
29
+ results: NormalizedResult[];
30
+ }
31
+ export interface NormalizedSpec {
32
+ title: string;
33
+ file: string;
34
+ ok: boolean;
35
+ tests: NormalizedTest[];
36
+ }
37
+ export interface NormalizedSuite {
38
+ title: string;
39
+ file: string;
40
+ specs: NormalizedSpec[];
41
+ suites: NormalizedSuite[];
42
+ }
43
+ export interface NormalizedReport {
44
+ suites: NormalizedSuite[];
45
+ errorMessages: string[];
46
+ stats: Record<string, unknown>;
47
+ }
48
+ export interface NormalizeOptions {
49
+ /** Absolute fixture dir, replaced with a stable token in messages. */
50
+ cwd: string;
51
+ /** Include attachment `hasContent` in the comparison (see above). */
52
+ compareAttachmentContent?: boolean;
53
+ }
54
+ export declare function normalizeJsonReport(report: Record<string, unknown>, opts: NormalizeOptions): NormalizedReport;
55
+ /**
56
+ * Per-result step signatures from a raw blob event stream, keyed by
57
+ * `testId:retry` (stable across both reporters — they see the same run).
58
+ * The merged JSON report drops steps, so step parity is checked here.
59
+ */
60
+ export declare function stepSignatures(events: BlobEvent[]): Map<string, string[]>;
61
+ /** Event method names present in `reference` but absent from `candidate`. */
62
+ export declare function missingEventMethods(reference: BlobEvent[], candidate: BlobEvent[]): string[];
63
+ //# sourceMappingURL=parity-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parity-utils.d.ts","sourceRoot":"","sources":["../../src/reporter/parity-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAiD3C,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,WAAW,EAAE,oBAAoB,EAAE,CAAC;CACrC;AAED,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACtD,OAAO,EAAE,gBAAgB,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,EAAE,cAAc,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,wBAAwB,CAAC,EAAE,OAAO,CAAC;CACpC;AAwED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,gBAAgB,GACrB,gBAAgB,CAclB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CA+BzE;AAED,6EAA6E;AAC7E,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,SAAS,EAAE,EACtB,SAAS,EAAE,SAAS,EAAE,GACrB,MAAM,EAAE,CASV"}
@@ -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.1",
3
+ "version": "0.58.3",
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
  },
@@ -54,8 +54,8 @@
54
54
  "puppeteer-extra-plugin-recaptcha": "^3.6.8",
55
55
  "rimraf": "^6.0.1",
56
56
  "ts-morph": "^23.0.0",
57
- "@empiricalrun/cua": "^0.4.1",
58
57
  "@empiricalrun/llm": "^0.27.0",
58
+ "@empiricalrun/cua": "^0.4.1",
59
59
  "@empiricalrun/r2-uploader": "^0.10.1",
60
60
  "@empiricalrun/reporter": "^0.29.1"
61
61
  },
@@ -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"]}