@piwitests/reporter 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5700,6 +5700,9 @@ function boxCaptureFrames(extraPrefixes = []) {
5700
5700
  } catch {
5701
5701
  }
5702
5702
  }
5703
+ function callMethod(target, prop, args) {
5704
+ return target[prop](...args);
5705
+ }
5703
5706
  function internalCall(owner, run) {
5704
5707
  const wrap = owner?._wrapApiCall;
5705
5708
  if (typeof wrap !== "function") return run();
@@ -13157,6 +13160,143 @@ function readPageInteraction() {
13157
13160
  return out;
13158
13161
  }
13159
13162
 
13163
+ // src/internal/capture/api-requests.ts
13164
+ var SENDING_METHODS = /* @__PURE__ */ new Set(["fetch", "get", "post", "put", "patch", "delete", "head"]);
13165
+ var PROXIES = /* @__PURE__ */ new WeakMap();
13166
+ var OBSERVED = /* @__PURE__ */ new WeakSet();
13167
+ var PATCHED_REQUESTS = /* @__PURE__ */ new WeakSet();
13168
+ function observeApiRequests(playwright, recorder) {
13169
+ const apiRequest = playwright?.request;
13170
+ if (!apiRequest || typeof apiRequest !== "object" || typeof apiRequest.newContext !== "function") return;
13171
+ if (PATCHED_REQUESTS.has(apiRequest)) return;
13172
+ PATCHED_REQUESTS.add(apiRequest);
13173
+ const create = apiRequest.newContext.bind(apiRequest);
13174
+ apiRequest.newContext = async function newContext(...args) {
13175
+ const context = await create(...args);
13176
+ return observeApiRequestContext(context, recorder, baseUrlOption(args[0]));
13177
+ };
13178
+ }
13179
+ function observeApiRequestContext(context, recorder, baseURL = null) {
13180
+ if (!context || typeof context !== "object" || OBSERVED.has(context)) return context;
13181
+ const known = PROXIES.get(context);
13182
+ if (known) return known;
13183
+ const forwarders = /* @__PURE__ */ new Map();
13184
+ const proxy = new Proxy(context, {
13185
+ get(target, prop) {
13186
+ const value = Reflect.get(target, prop);
13187
+ if (typeof value !== "function" || prop === "constructor") return value;
13188
+ const cached = forwarders.get(prop);
13189
+ if (cached?.method === value) return cached.forward;
13190
+ const forward = typeof prop === "string" && SENDING_METHODS.has(prop) ? sendingMethod(target, prop, recorder, baseURL) : value.bind(target);
13191
+ forwarders.set(prop, { method: value, forward });
13192
+ return forward;
13193
+ }
13194
+ });
13195
+ PROXIES.set(context, proxy);
13196
+ OBSERVED.add(proxy);
13197
+ return proxy;
13198
+ }
13199
+ function sendingMethod(target, prop, recorder, baseURL) {
13200
+ return function send(...args) {
13201
+ const record = recorder();
13202
+ if (!record) return callMethod(target, prop, args);
13203
+ const startedAt = Date.now();
13204
+ const result = callMethod(target, prop, args);
13205
+ if (typeof result?.then !== "function") return result;
13206
+ const report = (response, error) => {
13207
+ try {
13208
+ record({
13209
+ method: methodOf(prop, args),
13210
+ url: urlOf(args[0]),
13211
+ baseURL,
13212
+ startedAt,
13213
+ endedAt: Date.now(),
13214
+ response,
13215
+ error
13216
+ });
13217
+ } catch {
13218
+ }
13219
+ };
13220
+ return result.then(
13221
+ (response) => {
13222
+ report(response);
13223
+ return response;
13224
+ },
13225
+ (error) => {
13226
+ report(null, error);
13227
+ throw error;
13228
+ }
13229
+ );
13230
+ };
13231
+ }
13232
+ function methodOf(prop, args) {
13233
+ if (prop !== "fetch") return prop.toUpperCase();
13234
+ const option = args[1]?.method;
13235
+ if (typeof option === "string" && option) return option.toUpperCase();
13236
+ const request4 = args[0];
13237
+ const method = typeof request4?.method === "function" ? request4.method() : null;
13238
+ return typeof method === "string" && method ? method.toUpperCase() : "GET";
13239
+ }
13240
+ function urlOf(target) {
13241
+ if (typeof target === "string") return target;
13242
+ const url = typeof target?.url === "function" ? target.url() : null;
13243
+ return typeof url === "string" ? url : null;
13244
+ }
13245
+ function baseUrlOption(options) {
13246
+ const baseURL = options?.baseURL;
13247
+ return typeof baseURL === "string" && baseURL ? baseURL : null;
13248
+ }
13249
+ var MAX_FAILURE_LENGTH = 300;
13250
+ var API_ERROR_PREFIX = /^apiRequestContext\.\w+: /;
13251
+ var STATUS_FAILURE = /^(\d{3}) /;
13252
+ function apiCallEntry(call, baseURL) {
13253
+ const response = call.response;
13254
+ if (response) {
13255
+ const timing = responseTiming(response) ?? { startTime: call.startedAt, duration: call.endedAt - call.startedAt };
13256
+ return {
13257
+ method: call.method,
13258
+ url: response.url(),
13259
+ status: response.status(),
13260
+ duration: timing.duration,
13261
+ startTime: timing.startTime
13262
+ };
13263
+ }
13264
+ const url = resolveUrl(call.url, call.baseURL ?? baseURL);
13265
+ if (!url) return null;
13266
+ const failure = failureText(call.error);
13267
+ const status = STATUS_FAILURE.exec(failure);
13268
+ return {
13269
+ method: call.method,
13270
+ url,
13271
+ status: status ? Number(status[1]) : 0,
13272
+ duration: Math.max(0, call.endedAt - call.startedAt),
13273
+ startTime: call.startedAt,
13274
+ ...status ? {} : { failure }
13275
+ };
13276
+ }
13277
+ function responseTiming(response) {
13278
+ if (typeof response.timing !== "function") return null;
13279
+ const timing = response.timing();
13280
+ if (!(timing.startTime > 0) || !(timing.responseEnd >= 0)) return null;
13281
+ return {
13282
+ startTime: timing.startTime,
13283
+ duration: Math.max(0, Math.round(timing.responseEnd - Math.max(0, timing.requestStart)))
13284
+ };
13285
+ }
13286
+ function resolveUrl(url, baseURL) {
13287
+ if (!url) return null;
13288
+ try {
13289
+ return new URL(url, baseURL ?? void 0).toString();
13290
+ } catch {
13291
+ return url;
13292
+ }
13293
+ }
13294
+ function failureText(error) {
13295
+ const message = error instanceof Error ? error.message : String(error);
13296
+ const line = message.split("\n", 1)[0].replace(API_ERROR_PREFIX, "").trim();
13297
+ return (line || "failed").slice(0, MAX_FAILURE_LENGTH);
13298
+ }
13299
+
13160
13300
  // src/internal/capture/capture-fixtures.ts
13161
13301
  function computeCoreVitals(lcpEntries, shiftEntries, eventEntries) {
13162
13302
  const lastLcp = lcpEntries && lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : null;
@@ -13217,7 +13357,8 @@ function createSink() {
13217
13357
  pause: null,
13218
13358
  locatorPages: new LocatorPageLog(),
13219
13359
  codeReach: null,
13220
- serverCoverage: /* @__PURE__ */ new Map()
13360
+ serverCoverage: /* @__PURE__ */ new Map(),
13361
+ baseURL: null
13221
13362
  };
13222
13363
  }
13223
13364
  function maybeStartCodeReach(page) {
@@ -13672,9 +13813,6 @@ function startElementCapture(sink, page, target, seq, callerLocation, used) {
13672
13813
  })();
13673
13814
  sink.capturePromises.push(resolveAttrs);
13674
13815
  }
13675
- function callMethod(target, prop, args) {
13676
- return target[prop](...args);
13677
- }
13678
13816
  function recordLocatorPage(sink, page, locator, location, arrival) {
13679
13817
  try {
13680
13818
  sink.locatorPages.record({ location, locator: String(locator), url: page.url(), arrival });
@@ -13841,6 +13979,7 @@ function instrumentPage(page) {
13841
13979
  if (!page || INSTRUMENTED_PAGES.has(page)) return;
13842
13980
  INSTRUMENTED_PAGES.add(page);
13843
13981
  void maybeStartCodeReach(page);
13982
+ observeOwnApiRequests(page);
13844
13983
  const probeSink = currentSink;
13845
13984
  if (probeSink?.probeItem && !probeSink.probeInterception) {
13846
13985
  void installProbeInterception(page, probeSink.probeItem).then((interception) => {
@@ -14000,25 +14139,7 @@ function recordRequest(sink, request4, end, response) {
14000
14139
  if (timing.requestStart < 0 && typeof response.serverAddr === "function") {
14001
14140
  if (await response.serverAddr().catch(() => void 0) === null) entry.fulfilled = true;
14002
14141
  }
14003
- const headers = response.headers();
14004
- const contentType = headers["content-type"];
14005
- if (contentType) entry.contentType = contentType.split(";")[0].trim();
14006
- const logHeader = headers["x-piwi-logs"];
14007
- if (logHeader) {
14008
- try {
14009
- entry.serverLogs = JSON.parse((0, import_node_zlib2.gunzipSync)(Buffer.from(logHeader, "base64")).toString("utf-8"));
14010
- } catch {
14011
- }
14012
- }
14013
- const coverageHeader = headers["x-piwi-coverage"];
14014
- if (coverageHeader && serverCoverageInWorker()) recordServerCoverageIds(sink, url, coverageHeader);
14015
- const traceHeader = headers["x-piwi-trace"];
14016
- if (traceHeader && process.env.PIWI_CAPTURE_SERVER_TRACES !== "false") {
14017
- try {
14018
- entry.serverTraces = JSON.parse((0, import_node_zlib2.gunzipSync)(Buffer.from(traceHeader, "base64")).toString("utf-8"));
14019
- } catch {
14020
- }
14021
- }
14142
+ readResponseHeaders(sink, entry, url, response.headers());
14022
14143
  }
14023
14144
  sink.networkRequests.push(entry);
14024
14145
  } catch {
@@ -14026,6 +14147,48 @@ function recordRequest(sink, request4, end, response) {
14026
14147
  })();
14027
14148
  sink.pendingHandlers.push(p);
14028
14149
  }
14150
+ function readResponseHeaders(sink, entry, url, headers) {
14151
+ const contentType = headers["content-type"];
14152
+ if (contentType) entry.contentType = contentType.split(";")[0].trim();
14153
+ const logHeader = headers["x-piwi-logs"];
14154
+ if (logHeader) {
14155
+ try {
14156
+ entry.serverLogs = JSON.parse((0, import_node_zlib2.gunzipSync)(Buffer.from(logHeader, "base64")).toString("utf-8"));
14157
+ } catch {
14158
+ }
14159
+ }
14160
+ const coverageHeader = headers["x-piwi-coverage"];
14161
+ if (coverageHeader && serverCoverageInWorker()) recordServerCoverageIds(sink, url, coverageHeader);
14162
+ const traceHeader = headers["x-piwi-trace"];
14163
+ if (traceHeader && process.env.PIWI_CAPTURE_SERVER_TRACES !== "false") {
14164
+ try {
14165
+ entry.serverTraces = JSON.parse((0, import_node_zlib2.gunzipSync)(Buffer.from(traceHeader, "base64")).toString("utf-8"));
14166
+ } catch {
14167
+ }
14168
+ }
14169
+ }
14170
+ function recordApiCall(sink, call) {
14171
+ try {
14172
+ const entry = apiCallEntry(call, sink.baseURL);
14173
+ if (!entry) return;
14174
+ if (call.response) readResponseHeaders(sink, entry, String(entry.url), call.response.headers());
14175
+ sink.networkRequests.push(entry);
14176
+ } catch {
14177
+ }
14178
+ }
14179
+ function apiCallRecorder() {
14180
+ const sink = currentSink;
14181
+ return sink ? (call) => recordApiCall(sink, call) : null;
14182
+ }
14183
+ function observeOwnApiRequests(owner) {
14184
+ try {
14185
+ const holder = owner;
14186
+ if (holder.request && typeof holder.request === "object") {
14187
+ holder.request = observeApiRequestContext(holder.request, apiCallRecorder);
14188
+ }
14189
+ } catch {
14190
+ }
14191
+ }
14029
14192
  function recordOpenRequests(sink) {
14030
14193
  for (const [request4, response] of sink.openResponses) {
14031
14194
  RECORDED_OPEN.add(request4);
@@ -14039,6 +14202,7 @@ async function awaitFlakeConditions() {
14039
14202
  function instrumentContext(context) {
14040
14203
  if (!context || INSTRUMENTED_CONTEXTS.has(context)) return;
14041
14204
  INSTRUMENTED_CONTEXTS.add(context);
14205
+ observeOwnApiRequests(context);
14042
14206
  if (process.env.PIWI_CAPTURE_LOCATORS !== "false" && typeof context.addInitScript === "function") {
14043
14207
  void Promise.resolve(context.addInitScript({ content: PROBE_INIT_SCRIPT })).catch(() => {
14044
14208
  });
@@ -14293,14 +14457,17 @@ async function flushSink(sink, testInfo) {
14293
14457
  }
14294
14458
  }
14295
14459
  var piwiFixtures = {
14296
- // Auto, worker-scoped: the worker's resource ledger. Set up before any hook
14297
- // (worker fixtures come first), so a `beforeAll` context is in it too, and
14298
- // torn down after the browser closed, so its census sees what only the
14299
- // worker's shutdown closed. Depends on `playwright` alone, which launches nothing.
14460
+ // Auto, worker-scoped: the worker's resource ledger, and the API request
14461
+ // contexts its tests create, handed out behind the call proxy. Set up before
14462
+ // any hook (worker fixtures come first), so a `beforeAll` context is in the
14463
+ // ledger and records the calls the tests make through it, and torn down after
14464
+ // the browser closed, so its census sees what only the worker's shutdown
14465
+ // closed. Depends on `playwright` alone, which launches nothing.
14300
14466
  piwiResources: [
14301
14467
  async ({ playwright }, use, workerInfo) => {
14302
14468
  boxCaptureFrames();
14303
14469
  startResourceLedger(playwright, workerInfo.workerIndex);
14470
+ observeApiRequests(playwright, apiCallRecorder);
14304
14471
  try {
14305
14472
  await use();
14306
14473
  } finally {
@@ -14331,12 +14498,13 @@ var piwiFixtures = {
14331
14498
  // (attach the collected data) at teardown. Runs for every test without being
14332
14499
  // requested, so suites that never destructure `page` are still captured.
14333
14500
  piwiCapture: [
14334
- async ({ screenshot }, use, testInfo) => {
14501
+ async ({ screenshot, baseURL }, use, testInfo) => {
14335
14502
  boxCaptureFrames();
14336
14503
  const conflict = labModeConflict();
14337
14504
  if (conflict) throw new Error(conflict);
14338
14505
  const sink = createSink();
14339
14506
  sink.testInfo = testInfo;
14507
+ sink.baseURL = baseURL ?? null;
14340
14508
  if (keepsFailureScreenshots(screenshot, testInfo.retry)) sink.failureScreenshot = screenshot;
14341
14509
  const pauseSet = currentPauseSet();
14342
14510
  if (pauseSet.size) {
@@ -35,6 +35,13 @@ declare class LocatorPageLog {
35
35
  * layout leaves Playwright's defaults untouched.
36
36
  */
37
37
  declare function boxCaptureFrames(extraPrefixes?: readonly string[]): void;
38
+ /**
39
+ * Call a Playwright method on its own receiver. Playwright names an API call
40
+ * after the method frame the call enters, so the call keeps its real name
41
+ * (`locator.fill: Timeout …`); a call through `Function.prototype.apply` is
42
+ * named `apply`.
43
+ */
44
+ declare function callMethod(target: object, prop: string | symbol, args: unknown[]): unknown;
38
45
  /**
39
46
  * Run a page read as an internal Playwright call: every API call made inside
40
47
  * `run` is kept out of the test's steps and the trace. `owner` is the page (or
@@ -553,13 +560,6 @@ declare function ariaSnapshotBestEffort(target: Locator, timeout?: number): Prom
553
560
  * yields null and the capture proceeds with the YAML alone.
554
561
  */
555
562
  declare function ariaSnapshotJSONBestEffort(target: Locator, timeout?: number): Promise<string | null>;
556
- /**
557
- * Call a Playwright method on its own receiver. Playwright names an API call
558
- * after the method frame the call enters, so the call keeps its real name
559
- * (`locator.fill: Timeout …`); a call through `Function.prototype.apply` is
560
- * named `apply`.
561
- */
562
- declare function callMethod(target: object, prop: string | symbol, args: unknown[]): unknown;
563
563
  /**
564
564
  * The fixtures `piwiFixtures` / `extendPiwiFixtures` contribute. The single
565
565
  * added fixture is `piwiCapture`: an auto, test-scoped teardown hook that
@@ -575,9 +575,11 @@ interface PiwiFixtures {
575
575
  * The worker fixture `piwiFixtures` / `extendPiwiFixtures` contribute:
576
576
  * `piwiResources`, an auto, worker-scoped fixture that keeps the worker's
577
577
  * resource ledger (the browsers, contexts, pages and API request contexts its
578
- * tests open) from before the first hook to the worker's shutdown. Its name is
579
- * **reserved** like `piwiCapture`'s: a fixture of your own with that name
580
- * replaces it and turns the ledger off.
578
+ * tests open) from before the first hook to the worker's shutdown, and records
579
+ * the calls tests make through the API request contexts `playwright.request`
580
+ * creates, the `request` fixture's among them. Its name is **reserved** like
581
+ * `piwiCapture`'s: a fixture of your own with that name replaces it and turns
582
+ * the ledger and those calls' capture off.
581
583
  */
582
584
  interface PiwiWorkerFixtures {
583
585
  piwiResources: void;
@@ -588,8 +590,11 @@ interface PiwiWorkerFixtures {
588
590
  *
589
591
  * Capture is wired at the `browser` level, so it works whether a test uses the
590
592
  * standard `page` fixture or builds its own pages from `browser` /
591
- * `browser.newContext()`. Collected data is attached as `piwi-*`
592
- * test-info attachments which the Piwi Dashboard reporter parses on `onTestEnd`.
593
+ * `browser.newContext()`. The calls a test makes through an API request
594
+ * context (the `request` fixture, `playwright.request.newContext()`,
595
+ * `page.request`) are recorded with its network requests. Collected data is
596
+ * attached as `piwi-*` test-info attachments which the Piwi Dashboard reporter
597
+ * parses on `onTestEnd`.
593
598
  */
594
599
  declare const piwiFixtures: Fixtures<PiwiFixtures, PiwiWorkerFixtures, PlaywrightTestArgs & PlaywrightTestOptions, PlaywrightWorkerArgs & PlaywrightWorkerOptions>;
595
600
  /**
@@ -4039,6 +4039,9 @@ function boxCaptureFrames(extraPrefixes = []) {
4039
4039
  } catch {
4040
4040
  }
4041
4041
  }
4042
+ function callMethod(target, prop, args) {
4043
+ return target[prop](...args);
4044
+ }
4042
4045
  function internalCall(owner, run) {
4043
4046
  const wrap = owner?._wrapApiCall;
4044
4047
  if (typeof wrap !== "function") return run();
@@ -5522,6 +5525,143 @@ function readPageInteraction() {
5522
5525
  return out;
5523
5526
  }
5524
5527
 
5528
+ // src/internal/capture/api-requests.ts
5529
+ var SENDING_METHODS = /* @__PURE__ */ new Set(["fetch", "get", "post", "put", "patch", "delete", "head"]);
5530
+ var PROXIES = /* @__PURE__ */ new WeakMap();
5531
+ var OBSERVED = /* @__PURE__ */ new WeakSet();
5532
+ var PATCHED_REQUESTS = /* @__PURE__ */ new WeakSet();
5533
+ function observeApiRequests(playwright, recorder) {
5534
+ const apiRequest = playwright?.request;
5535
+ if (!apiRequest || typeof apiRequest !== "object" || typeof apiRequest.newContext !== "function") return;
5536
+ if (PATCHED_REQUESTS.has(apiRequest)) return;
5537
+ PATCHED_REQUESTS.add(apiRequest);
5538
+ const create = apiRequest.newContext.bind(apiRequest);
5539
+ apiRequest.newContext = async function newContext(...args) {
5540
+ const context = await create(...args);
5541
+ return observeApiRequestContext(context, recorder, baseUrlOption(args[0]));
5542
+ };
5543
+ }
5544
+ function observeApiRequestContext(context, recorder, baseURL = null) {
5545
+ if (!context || typeof context !== "object" || OBSERVED.has(context)) return context;
5546
+ const known = PROXIES.get(context);
5547
+ if (known) return known;
5548
+ const forwarders = /* @__PURE__ */ new Map();
5549
+ const proxy = new Proxy(context, {
5550
+ get(target, prop) {
5551
+ const value = Reflect.get(target, prop);
5552
+ if (typeof value !== "function" || prop === "constructor") return value;
5553
+ const cached = forwarders.get(prop);
5554
+ if (cached?.method === value) return cached.forward;
5555
+ const forward = typeof prop === "string" && SENDING_METHODS.has(prop) ? sendingMethod(target, prop, recorder, baseURL) : value.bind(target);
5556
+ forwarders.set(prop, { method: value, forward });
5557
+ return forward;
5558
+ }
5559
+ });
5560
+ PROXIES.set(context, proxy);
5561
+ OBSERVED.add(proxy);
5562
+ return proxy;
5563
+ }
5564
+ function sendingMethod(target, prop, recorder, baseURL) {
5565
+ return function send(...args) {
5566
+ const record = recorder();
5567
+ if (!record) return callMethod(target, prop, args);
5568
+ const startedAt = Date.now();
5569
+ const result = callMethod(target, prop, args);
5570
+ if (typeof result?.then !== "function") return result;
5571
+ const report = (response, error) => {
5572
+ try {
5573
+ record({
5574
+ method: methodOf(prop, args),
5575
+ url: urlOf(args[0]),
5576
+ baseURL,
5577
+ startedAt,
5578
+ endedAt: Date.now(),
5579
+ response,
5580
+ error
5581
+ });
5582
+ } catch {
5583
+ }
5584
+ };
5585
+ return result.then(
5586
+ (response) => {
5587
+ report(response);
5588
+ return response;
5589
+ },
5590
+ (error) => {
5591
+ report(null, error);
5592
+ throw error;
5593
+ }
5594
+ );
5595
+ };
5596
+ }
5597
+ function methodOf(prop, args) {
5598
+ if (prop !== "fetch") return prop.toUpperCase();
5599
+ const option = args[1]?.method;
5600
+ if (typeof option === "string" && option) return option.toUpperCase();
5601
+ const request = args[0];
5602
+ const method = typeof request?.method === "function" ? request.method() : null;
5603
+ return typeof method === "string" && method ? method.toUpperCase() : "GET";
5604
+ }
5605
+ function urlOf(target) {
5606
+ if (typeof target === "string") return target;
5607
+ const url = typeof target?.url === "function" ? target.url() : null;
5608
+ return typeof url === "string" ? url : null;
5609
+ }
5610
+ function baseUrlOption(options) {
5611
+ const baseURL = options?.baseURL;
5612
+ return typeof baseURL === "string" && baseURL ? baseURL : null;
5613
+ }
5614
+ var MAX_FAILURE_LENGTH = 300;
5615
+ var API_ERROR_PREFIX = /^apiRequestContext\.\w+: /;
5616
+ var STATUS_FAILURE = /^(\d{3}) /;
5617
+ function apiCallEntry(call, baseURL) {
5618
+ const response = call.response;
5619
+ if (response) {
5620
+ const timing = responseTiming(response) ?? { startTime: call.startedAt, duration: call.endedAt - call.startedAt };
5621
+ return {
5622
+ method: call.method,
5623
+ url: response.url(),
5624
+ status: response.status(),
5625
+ duration: timing.duration,
5626
+ startTime: timing.startTime
5627
+ };
5628
+ }
5629
+ const url = resolveUrl(call.url, call.baseURL ?? baseURL);
5630
+ if (!url) return null;
5631
+ const failure = failureText(call.error);
5632
+ const status = STATUS_FAILURE.exec(failure);
5633
+ return {
5634
+ method: call.method,
5635
+ url,
5636
+ status: status ? Number(status[1]) : 0,
5637
+ duration: Math.max(0, call.endedAt - call.startedAt),
5638
+ startTime: call.startedAt,
5639
+ ...status ? {} : { failure }
5640
+ };
5641
+ }
5642
+ function responseTiming(response) {
5643
+ if (typeof response.timing !== "function") return null;
5644
+ const timing = response.timing();
5645
+ if (!(timing.startTime > 0) || !(timing.responseEnd >= 0)) return null;
5646
+ return {
5647
+ startTime: timing.startTime,
5648
+ duration: Math.max(0, Math.round(timing.responseEnd - Math.max(0, timing.requestStart)))
5649
+ };
5650
+ }
5651
+ function resolveUrl(url, baseURL) {
5652
+ if (!url) return null;
5653
+ try {
5654
+ return new URL(url, baseURL ?? void 0).toString();
5655
+ } catch {
5656
+ return url;
5657
+ }
5658
+ }
5659
+ function failureText(error) {
5660
+ const message = error instanceof Error ? error.message : String(error);
5661
+ const line = message.split("\n", 1)[0].replace(API_ERROR_PREFIX, "").trim();
5662
+ return (line || "failed").slice(0, MAX_FAILURE_LENGTH);
5663
+ }
5664
+
5525
5665
  // src/internal/capture/capture-fixtures.ts
5526
5666
  function computeCoreVitals(lcpEntries, shiftEntries, eventEntries) {
5527
5667
  const lastLcp = lcpEntries && lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : null;
@@ -5582,7 +5722,8 @@ function createSink() {
5582
5722
  pause: null,
5583
5723
  locatorPages: new LocatorPageLog(),
5584
5724
  codeReach: null,
5585
- serverCoverage: /* @__PURE__ */ new Map()
5725
+ serverCoverage: /* @__PURE__ */ new Map(),
5726
+ baseURL: null
5586
5727
  };
5587
5728
  }
5588
5729
  function maybeStartCodeReach(page) {
@@ -6037,9 +6178,6 @@ function startElementCapture(sink, page, target, seq, callerLocation, used) {
6037
6178
  })();
6038
6179
  sink.capturePromises.push(resolveAttrs);
6039
6180
  }
6040
- function callMethod(target, prop, args) {
6041
- return target[prop](...args);
6042
- }
6043
6181
  function recordLocatorPage(sink, page, locator, location, arrival) {
6044
6182
  try {
6045
6183
  sink.locatorPages.record({ location, locator: String(locator), url: page.url(), arrival });
@@ -6206,6 +6344,7 @@ function instrumentPage(page) {
6206
6344
  if (!page || INSTRUMENTED_PAGES.has(page)) return;
6207
6345
  INSTRUMENTED_PAGES.add(page);
6208
6346
  void maybeStartCodeReach(page);
6347
+ observeOwnApiRequests(page);
6209
6348
  const probeSink = currentSink;
6210
6349
  if (probeSink?.probeItem && !probeSink.probeInterception) {
6211
6350
  void installProbeInterception(page, probeSink.probeItem).then((interception) => {
@@ -6365,25 +6504,7 @@ function recordRequest(sink, request, end, response) {
6365
6504
  if (timing.requestStart < 0 && typeof response.serverAddr === "function") {
6366
6505
  if (await response.serverAddr().catch(() => void 0) === null) entry.fulfilled = true;
6367
6506
  }
6368
- const headers = response.headers();
6369
- const contentType = headers["content-type"];
6370
- if (contentType) entry.contentType = contentType.split(";")[0].trim();
6371
- const logHeader = headers["x-piwi-logs"];
6372
- if (logHeader) {
6373
- try {
6374
- entry.serverLogs = JSON.parse((0, import_node_zlib2.gunzipSync)(Buffer.from(logHeader, "base64")).toString("utf-8"));
6375
- } catch {
6376
- }
6377
- }
6378
- const coverageHeader = headers["x-piwi-coverage"];
6379
- if (coverageHeader && serverCoverageInWorker()) recordServerCoverageIds(sink, url, coverageHeader);
6380
- const traceHeader = headers["x-piwi-trace"];
6381
- if (traceHeader && process.env.PIWI_CAPTURE_SERVER_TRACES !== "false") {
6382
- try {
6383
- entry.serverTraces = JSON.parse((0, import_node_zlib2.gunzipSync)(Buffer.from(traceHeader, "base64")).toString("utf-8"));
6384
- } catch {
6385
- }
6386
- }
6507
+ readResponseHeaders(sink, entry, url, response.headers());
6387
6508
  }
6388
6509
  sink.networkRequests.push(entry);
6389
6510
  } catch {
@@ -6391,6 +6512,48 @@ function recordRequest(sink, request, end, response) {
6391
6512
  })();
6392
6513
  sink.pendingHandlers.push(p);
6393
6514
  }
6515
+ function readResponseHeaders(sink, entry, url, headers) {
6516
+ const contentType = headers["content-type"];
6517
+ if (contentType) entry.contentType = contentType.split(";")[0].trim();
6518
+ const logHeader = headers["x-piwi-logs"];
6519
+ if (logHeader) {
6520
+ try {
6521
+ entry.serverLogs = JSON.parse((0, import_node_zlib2.gunzipSync)(Buffer.from(logHeader, "base64")).toString("utf-8"));
6522
+ } catch {
6523
+ }
6524
+ }
6525
+ const coverageHeader = headers["x-piwi-coverage"];
6526
+ if (coverageHeader && serverCoverageInWorker()) recordServerCoverageIds(sink, url, coverageHeader);
6527
+ const traceHeader = headers["x-piwi-trace"];
6528
+ if (traceHeader && process.env.PIWI_CAPTURE_SERVER_TRACES !== "false") {
6529
+ try {
6530
+ entry.serverTraces = JSON.parse((0, import_node_zlib2.gunzipSync)(Buffer.from(traceHeader, "base64")).toString("utf-8"));
6531
+ } catch {
6532
+ }
6533
+ }
6534
+ }
6535
+ function recordApiCall(sink, call) {
6536
+ try {
6537
+ const entry = apiCallEntry(call, sink.baseURL);
6538
+ if (!entry) return;
6539
+ if (call.response) readResponseHeaders(sink, entry, String(entry.url), call.response.headers());
6540
+ sink.networkRequests.push(entry);
6541
+ } catch {
6542
+ }
6543
+ }
6544
+ function apiCallRecorder() {
6545
+ const sink = currentSink;
6546
+ return sink ? (call) => recordApiCall(sink, call) : null;
6547
+ }
6548
+ function observeOwnApiRequests(owner) {
6549
+ try {
6550
+ const holder = owner;
6551
+ if (holder.request && typeof holder.request === "object") {
6552
+ holder.request = observeApiRequestContext(holder.request, apiCallRecorder);
6553
+ }
6554
+ } catch {
6555
+ }
6556
+ }
6394
6557
  function recordOpenRequests(sink) {
6395
6558
  for (const [request, response] of sink.openResponses) {
6396
6559
  RECORDED_OPEN.add(request);
@@ -6404,6 +6567,7 @@ async function awaitFlakeConditions() {
6404
6567
  function instrumentContext(context) {
6405
6568
  if (!context || INSTRUMENTED_CONTEXTS.has(context)) return;
6406
6569
  INSTRUMENTED_CONTEXTS.add(context);
6570
+ observeOwnApiRequests(context);
6407
6571
  if (process.env.PIWI_CAPTURE_LOCATORS !== "false" && typeof context.addInitScript === "function") {
6408
6572
  void Promise.resolve(context.addInitScript({ content: PROBE_INIT_SCRIPT })).catch(() => {
6409
6573
  });
@@ -6658,14 +6822,17 @@ async function flushSink(sink, testInfo) {
6658
6822
  }
6659
6823
  }
6660
6824
  var piwiFixtures = {
6661
- // Auto, worker-scoped: the worker's resource ledger. Set up before any hook
6662
- // (worker fixtures come first), so a `beforeAll` context is in it too, and
6663
- // torn down after the browser closed, so its census sees what only the
6664
- // worker's shutdown closed. Depends on `playwright` alone, which launches nothing.
6825
+ // Auto, worker-scoped: the worker's resource ledger, and the API request
6826
+ // contexts its tests create, handed out behind the call proxy. Set up before
6827
+ // any hook (worker fixtures come first), so a `beforeAll` context is in the
6828
+ // ledger and records the calls the tests make through it, and torn down after
6829
+ // the browser closed, so its census sees what only the worker's shutdown
6830
+ // closed. Depends on `playwright` alone, which launches nothing.
6665
6831
  piwiResources: [
6666
6832
  async ({ playwright }, use, workerInfo) => {
6667
6833
  boxCaptureFrames();
6668
6834
  startResourceLedger(playwright, workerInfo.workerIndex);
6835
+ observeApiRequests(playwright, apiCallRecorder);
6669
6836
  try {
6670
6837
  await use();
6671
6838
  } finally {
@@ -6696,12 +6863,13 @@ var piwiFixtures = {
6696
6863
  // (attach the collected data) at teardown. Runs for every test without being
6697
6864
  // requested, so suites that never destructure `page` are still captured.
6698
6865
  piwiCapture: [
6699
- async ({ screenshot }, use, testInfo) => {
6866
+ async ({ screenshot, baseURL }, use, testInfo) => {
6700
6867
  boxCaptureFrames();
6701
6868
  const conflict = labModeConflict();
6702
6869
  if (conflict) throw new Error(conflict);
6703
6870
  const sink = createSink();
6704
6871
  sink.testInfo = testInfo;
6872
+ sink.baseURL = baseURL ?? null;
6705
6873
  if (keepsFailureScreenshots(screenshot, testInfo.retry)) sink.failureScreenshot = screenshot;
6706
6874
  const pauseSet = currentPauseSet();
6707
6875
  if (pauseSet.size) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "Playwright reporter that streams results, traces and HTML reports to a Piwi Dashboard instance",
5
5
  "url": "https://github.com/PiwiTests/platform",
6
6
  "homepage": "https://piwitests.dev",
@@ -19,7 +19,7 @@ Prefer the **Piwi MCP server** if it is connected to this agent (tools named `li
19
19
 
20
20
  3. **Group by cause.** Call `get_failure_groups` (or `list_clusters` / `get_cluster`) — failures that share a root cause are clustered, so you fix one thing, not five. Work cluster by cluster.
21
21
 
22
- 4. **Read the evidence per cluster.** Call `get_cluster_context` — the same SCM-grounded context the built-in diagnosis uses: representative errors, test steps, console logs, failing network requests, ARIA snapshots, and the **diff of files changed since the last green run**. If a diagnosis already exists, `get_cluster_diagnosis` returns its root cause and suggested fix.
22
+ 4. **Read the evidence per cluster.** Call `get_cluster_context` — the same SCM-grounded context the built-in diagnosis uses: representative errors, test steps, console logs, failing network requests, ARIA snapshots, and the **diff of files changed since the last green run**. If a diagnosis already exists, `get_cluster_diagnosis` returns its root cause and suggested fix. When the failure has no change behind it and the project records coverage, call `compare_coverage` with a failing execution's id: the code this run took that the last passing run did not (an error path, a fallback) is where to look.
23
23
 
24
24
  5. **Form the diagnosis.** Tie the failure to a cause with evidence: a selector that stopped matching, an assertion on changed copy, a slow/500 endpoint (`get_network_requests`), a race, a genuinely flaky test (check `get_test_stability_trend` — if it fails intermittently, treat it as flaky, not a regression). Point at the specific commit/file from the diff when the evidence supports it.
25
25
 
@@ -15,7 +15,7 @@ Prefer the **Piwi MCP server** if it is connected (`list_flaky_tests`, `get_flak
15
15
 
16
16
  1. **Rank the flaky tests.** Call `list_flaky_tests` for the project and take the top few by impact. Do not try to fix the whole list at once.
17
17
 
18
- 2. **Read the suspects.** For a chosen test, call `get_flake_profile`. Each suspect carries raw counts ("slow in 7 of 8 failures and 3 of 44 passes"), the condition that would test it, and, once the lab has run, its latest result (`lab`). `experiments` lists what the lab already found; if an experiment reproduced the test, skip to step 4. Read one failing execution (`get_test_run_case` / `explain_failure`) for the error at the moment it flaked.
18
+ 2. **Read the suspects.** For a chosen test, call `get_flake_profile`. Each suspect carries raw counts ("slow in 7 of 8 failures and 3 of 44 passes"), the condition that would test it, and, once the lab has run, its latest result (`lab`). `experiments` lists what the lab already found; if an experiment reproduced the test, skip to step 4. Read one failing execution (`get_test_run_case` / `explain_failure`) for the error at the moment it flaked. A test whose root cause is `order` depends on state an earlier test leaves behind: `compare_coverage` with its `testCaseId` names that test and the code whose path changes, so reproduce by running that test before this one in a single worker, and fix the shared state, not the wait.
19
19
 
20
20
  3. **Reproduce it.** Run the lab on the test (`plan_flake_experiment` gives the exact commands and the arms it will run, with an estimate):
21
21
 
@@ -14,7 +14,7 @@ drafts it from the graph, and opens it in the same change.
14
14
  ## How you reach Piwi
15
15
 
16
16
  Prefer the **Piwi MCP server** if it is connected (`list_scenario_gaps`,
17
- `draft_scenario`, `get_change_coverage`, `triage_gap`). Otherwise use the dashboard's **Gaps**
17
+ `draft_scenario`, `get_change_coverage`, `get_coverage`, `triage_gap`). Otherwise use the dashboard's **Gaps**
18
18
  view, or the reporter CLI with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` /
19
19
  `PIWI_PROJECT_NAME` set.
20
20
 
@@ -38,7 +38,11 @@ view, or the reporter CLI with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` /
38
38
  skeleton: a title, `piwi:` annotations from the nearest test, the graph path
39
39
  from a reached page to the gap as the step list, the catalog page-object
40
40
  methods that match the page, and a `TODO` assertion naming what to check.
41
- Nothing is committed — the skeleton is yours to finish.
41
+ Nothing is committed — the skeleton is yours to finish. When the gap points at
42
+ application code (an uncovered file, a handler, an error path), name the target
43
+ lines in it and call `get_coverage` with `projectId`, `file` and `lines`:
44
+ `nearest` is the existing test running most of the function around them, the
45
+ one whose setup to copy.
42
46
 
43
47
  4. **Finish the assertion.** The draft reaches the subject; you write the check.
44
48
  For a false-comfort gap, assert the effect the probe fault suppressed (the row
@@ -51,11 +55,33 @@ view, or the reporter CLI with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` /
51
55
  for the right reason: temporarily break the behavior it asserts and see it
52
56
  fail. Open it in the same pull request as the change that motivated it.
53
57
 
54
- 6. **Report.** State which gap you closed (class, subject, score) and how the new
55
- test asserts the missing behavior. If the gap had no reachable path, say so —
56
- the draft cannot invent an entry point, and the gap may need a fixture.
58
+ 6. **Prove it reaches its target lines.** When the project records server
59
+ coverage (an ASP.NET Core backend in a coverage build), run the new test with
60
+ it on and reporting to Piwi:
57
61
 
58
- 7. **Report back to Piwi.** Tell Piwi what you decided about the gap with
62
+ ```bash
63
+ PIWI_COVERAGE_SERVER=lines npx playwright test <file> -g "<title>"
64
+ ```
65
+
66
+ ```powershell
67
+ $env:PIWI_COVERAGE_SERVER='lines'; npx playwright test <file> -g "<title>"
68
+ ```
69
+
70
+ Then call `get_coverage` with `projectId`, `testFile`, `testTitle`, `file`
71
+ and `lines` (the target lines). `ran` lists the target lines it executed,
72
+ `notRan` those it did not, `notExecutable` those that cannot run (blank lines,
73
+ comments), and `unknown` those the capture could not tell: run it again, and
74
+ check the backend records `lines`, not only files. While a target line did not
75
+ run, compare your test with `nearest`, change the steps or the data that lead
76
+ to that code, and run again, until `allRan` is true. If `get_coverage` says the
77
+ project records no coverage, skip this step and say so in the report.
78
+
79
+ 7. **Report.** State which gap you closed (class, subject, score), how the new
80
+ test asserts the missing behavior, and which target lines it ran. If the gap
81
+ had no reachable path, say so — the draft cannot invent an entry point, and
82
+ the gap may need a fixture.
83
+
84
+ 8. **Report back to Piwi.** Tell Piwi what you decided about the gap with
59
85
  `triage_gap` (reporter or admin access): `verb: "accept"` for the gap you
60
86
  wrote the test for, `covered-by` with the covering test's id when a test you
61
87
  found already covers it, or `dismiss` with the reason (`not-worth-testing`,