@piwitests/reporter 0.4.3 → 0.5.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/reporter.js CHANGED
@@ -45,10 +45,35 @@ const metadata_collector_js_1 = require("./metadata-collector.js");
45
45
  const stream_manager_js_1 = require("./stream-manager.js");
46
46
  const step_analyzer_js_1 = require("./step-analyzer.js");
47
47
  const helpers_js_1 = require("./helpers.js");
48
+ const config_wrapper_js_1 = require("./config-wrapper.js");
48
49
  const serializer_js_1 = require("./serializer.js");
49
50
  const skip_classify_js_1 = require("./skip-classify.js");
50
51
  const run_submitter_js_1 = require("./run-submitter.js");
51
52
  const logger_js_1 = require("./logger.js");
53
+ /**
54
+ * Build the stored error text for a test result.
55
+ *
56
+ * Playwright's `error.message` carries the failure message and call log but no
57
+ * stack frames — those live on `error.stack`/`error.location`. The server's
58
+ * locator-healing lookup needs the failing call site to find the pre-captured
59
+ * snapshot for that locator, so when the message has no `at …` frame we append
60
+ * a synthetic one from `error.location`, relativized to the cwd so it matches
61
+ * the format the fixture records at capture time. The frame is appended after
62
+ * the message, where `extractMessageHead` already trims it off before
63
+ * fingerprinting — so failure clustering is unaffected.
64
+ */
65
+ function buildErrorText(result) {
66
+ const err = result.error;
67
+ if (!err)
68
+ return null;
69
+ let text = err.message ?? '';
70
+ const loc = err.location;
71
+ if (loc?.file && !/\n\s+at\s/.test(text)) {
72
+ const rel = path.relative(process.cwd(), loc.file).split(path.sep).join('/');
73
+ text += `\n at ${rel}:${loc.line}:${loc.column}`;
74
+ }
75
+ return text;
76
+ }
52
77
  /**
53
78
  * Piwi Dashboard Playwright reporter.
54
79
  *
@@ -81,7 +106,7 @@ class PiwiDashboardReporter {
81
106
  /** Track suite-level setup steps (beforeAll/afterAll) not tied to any test */
82
107
  this.setupSteps = [];
83
108
  this.options = (0, config_js_1.resolveOptions)(rawOptions);
84
- this.enabled = !!this.options.serverUrl;
109
+ this.enabled = this.options.enabled !== false && !!this.options.serverUrl;
85
110
  this.runLabel = this.options.runLabel || (0, helpers_js_1.detectCiRunLabel)();
86
111
  this.instanceId = (0, helpers_js_1.computeInstanceId)(this.options.projectName, this.runLabel);
87
112
  const logger = new logger_js_1.Logger(this.options.verbose ?? false);
@@ -177,9 +202,11 @@ class PiwiDashboardReporter {
177
202
  }
178
203
  /** Playwright reporter hook: called when a step (including hook/fixture) ends */
179
204
  onStepEnd(test, _result, step) {
205
+ const cat = step.category;
206
+ if (cat === 'pw:api')
207
+ return; // not surfaced as stream events; locator locations are captured in the fixture
180
208
  if (!this.enabled || !this.streamManager?.enabled)
181
209
  return;
182
- const cat = step.category;
183
210
  if (cat !== 'hook' && cat !== 'fixture')
184
211
  return;
185
212
  const workerIndex = (0, helpers_js_1.workerIndexOf)(_result);
@@ -223,7 +250,7 @@ class PiwiDashboardReporter {
223
250
  location: `${relativeFilePath}:${test.location.line}:${test.location.column}`,
224
251
  status,
225
252
  duration: result.duration,
226
- error: result.error ? result.error.message : null,
253
+ error: buildErrorText(result),
227
254
  retries: result.retry,
228
255
  workerIndex: (0, helpers_js_1.workerIndexOf)(result),
229
256
  shardIndex: this.shardInfo?.current ?? null,
@@ -249,6 +276,20 @@ class PiwiDashboardReporter {
249
276
  }
250
277
  if (this.options.collectPerformanceMetrics && result.attachments) {
251
278
  this.fileHandler.parsePerformanceAttachments(testCase, result.attachments);
279
+ // Locator snapshots arrive pre-stamped with their call-site `location`
280
+ // (captured in the fixture at action call time). No index correlation
281
+ // with pw:api steps — that was unreliable across workers/concurrent calls.
282
+ const locatorAttachment = this.options.captureLocators !== false
283
+ ? result.attachments.find((a) => a.name === 'piwi-dashboard-locators')
284
+ : undefined;
285
+ if (locatorAttachment?.body) {
286
+ try {
287
+ testCase.locatorSnapshots = JSON.parse(locatorAttachment.body.toString());
288
+ }
289
+ catch {
290
+ /* ignore parse errors */
291
+ }
292
+ }
252
293
  }
253
294
  switch (status) {
254
295
  case 'passed':
@@ -338,4 +379,5 @@ class PiwiDashboardReporter {
338
379
  }
339
380
  }
340
381
  exports.PiwiDashboardReporter = PiwiDashboardReporter;
382
+ PiwiDashboardReporter.wrapConfig = config_wrapper_js_1.wrapConfig;
341
383
  PiwiDashboardReporter.createGlobalSetup = helpers_js_1.createGlobalSetup;
@@ -87,6 +87,7 @@ class RunSubmitter {
87
87
  totalTests: run.totalTests,
88
88
  passedTests: run.passedTests,
89
89
  failedTests: run.failedTests,
90
+ timedOutTests: run.timedOutTests,
90
91
  skippedTests: run.skippedTests,
91
92
  didNotRunTests: run.didNotRunTests,
92
93
  environment: run.options.environment,
@@ -114,6 +115,7 @@ class RunSubmitter {
114
115
  totalTests: run.totalTests,
115
116
  passedTests: run.passedTests,
116
117
  failedTests: run.failedTests,
118
+ timedOutTests: run.timedOutTests,
117
119
  skippedTests: run.skippedTests,
118
120
  didNotRunTests: run.didNotRunTests,
119
121
  flakyTests,
@@ -64,6 +64,7 @@ function toWireTestCase(tc) {
64
64
  suitePath: rest.suitePath ?? null,
65
65
  suiteConfig: rest.suiteConfig ?? null,
66
66
  testAnnotations: rest.testAnnotations ?? null,
67
+ locatorSnapshots: rest.locatorSnapshots || null,
67
68
  };
68
69
  }
69
70
  /**
@@ -85,6 +86,7 @@ function serializeRun(payload, opts) {
85
86
  totalTests: payload.totalTests,
86
87
  passedTests: payload.passedTests,
87
88
  failedTests: payload.failedTests,
89
+ timedOutTests: payload.timedOutTests ?? 0,
88
90
  skippedTests: payload.skippedTests,
89
91
  didNotRunTests: payload.didNotRunTests ?? 0,
90
92
  environment: payload.environment ?? null,
@@ -28,6 +28,16 @@ export declare class StreamManager {
28
28
  private retryCount;
29
29
  private retryTimer;
30
30
  private readonly maxRetryDelay;
31
+ /**
32
+ * Idle heartbeat: while the run is open but no events are flowing (e.g. a single
33
+ * long test, or `beforeAll` setup), a periodic ping keeps the server's activity
34
+ * timestamp fresh so the stale-run reaper can tell a live run from a crashed one.
35
+ * The interval must stay well below the server's stale timeout.
36
+ */
37
+ private heartbeatTimer;
38
+ private heartbeatStopped;
39
+ private lastActivityAt;
40
+ private readonly heartbeatInterval;
31
41
  /** Tracks cases whose files have already been uploaded live, so `uploadRemaining` can skip them. */
32
42
  private readonly uploadedCaseFiles;
33
43
  private _enabled;
@@ -65,6 +75,10 @@ export declare class StreamManager {
65
75
  /** Flush all pending events to the server. Returns a promise that resolves to `true` on success or `false` on failure (events are re-queued for retry). */
66
76
  flush(): Promise<boolean> | null;
67
77
  private scheduleRetry;
78
+ private scheduleHeartbeat;
79
+ private sendHeartbeat;
80
+ /** Stop the idle heartbeat permanently (called when the run is wrapping up). */
81
+ private stopHeartbeat;
68
82
  /** Drain all pending and buffered events before the run finishes. Retries up to 10 times with exponential back-off. */
69
83
  drain(): Promise<void>;
70
84
  /** Schedule a live upload of trace and attachment files for a test case. Skips cases with no files. Concurrency is limited to 2 simultaneous uploads. */
@@ -89,6 +89,16 @@ class StreamManager {
89
89
  this.retryCount = 0;
90
90
  this.retryTimer = null;
91
91
  this.maxRetryDelay = 30000;
92
+ /**
93
+ * Idle heartbeat: while the run is open but no events are flowing (e.g. a single
94
+ * long test, or `beforeAll` setup), a periodic ping keeps the server's activity
95
+ * timestamp fresh so the stale-run reaper can tell a live run from a crashed one.
96
+ * The interval must stay well below the server's stale timeout.
97
+ */
98
+ this.heartbeatTimer = null;
99
+ this.heartbeatStopped = false;
100
+ this.lastActivityAt = 0;
101
+ this.heartbeatInterval = 15000;
92
102
  /** Tracks cases whose files have already been uploaded live, so `uploadRemaining` can skip them. */
93
103
  this.uploadedCaseFiles = new WeakSet();
94
104
  this._enabled = false;
@@ -165,6 +175,8 @@ class StreamManager {
165
175
  this._token = response.streamToken;
166
176
  this._enabled = true;
167
177
  this.logger.info(`Streaming enabled. Run ID: ${response.runId}`);
178
+ this.lastActivityAt = Date.now();
179
+ this.scheduleHeartbeat();
168
180
  if (this.pendingBeginEvents.length > 0) {
169
181
  this.pendingEvents = [...this.pendingBeginEvents, ...this.pendingEvents];
170
182
  this.pendingBeginEvents = [];
@@ -213,6 +225,7 @@ class StreamManager {
213
225
  .postJSON(`/api/test-runs/${this._runId}/events`, { streamToken: this._token, testCases: events }, this._auth)
214
226
  .then(() => {
215
227
  this.retryCount = 0;
228
+ this.lastActivityAt = Date.now();
216
229
  return true;
217
230
  }, () => {
218
231
  this.pendingEvents = events.concat(this.pendingEvents);
@@ -239,8 +252,46 @@ class StreamManager {
239
252
  this.flush();
240
253
  }, delay);
241
254
  }
255
+ // Schedule the next idle heartbeat. Self-rescheduling; cleared by stopHeartbeat.
256
+ scheduleHeartbeat() {
257
+ if (this.heartbeatStopped || this.heartbeatTimer)
258
+ return;
259
+ this.heartbeatTimer = setTimeout(() => {
260
+ this.heartbeatTimer = null;
261
+ void this.sendHeartbeat();
262
+ }, this.heartbeatInterval);
263
+ }
264
+ // Ping the server only when the run has actually been idle. Real event traffic
265
+ // already bumps the server's activity timestamp, so a recent flush (or pending
266
+ // events about to flush) makes the ping redundant — skip it and reschedule.
267
+ async sendHeartbeat() {
268
+ if (this.heartbeatStopped || !this._enabled || !this._runId || !this._token)
269
+ return;
270
+ const idleFor = Date.now() - this.lastActivityAt;
271
+ if (idleFor < this.heartbeatInterval || this.pendingEvents.length > 0) {
272
+ this.scheduleHeartbeat();
273
+ return;
274
+ }
275
+ try {
276
+ await this.httpClient.postJSON(`/api/test-runs/${this._runId}/heartbeat`, { streamToken: this._token }, this._auth);
277
+ this.lastActivityAt = Date.now();
278
+ }
279
+ catch (error) {
280
+ this.logger.debug(`Heartbeat failed: ${error.message}`);
281
+ }
282
+ this.scheduleHeartbeat();
283
+ }
284
+ /** Stop the idle heartbeat permanently (called when the run is wrapping up). */
285
+ stopHeartbeat() {
286
+ this.heartbeatStopped = true;
287
+ if (this.heartbeatTimer) {
288
+ clearTimeout(this.heartbeatTimer);
289
+ this.heartbeatTimer = null;
290
+ }
291
+ }
242
292
  /** Drain all pending and buffered events before the run finishes. Retries up to 10 times with exponential back-off. */
243
293
  async drain() {
294
+ this.stopHeartbeat();
244
295
  if (!this._enabled) {
245
296
  this.pendingEvents = [];
246
297
  this.flushPromises = [];
package/dist/types.d.ts CHANGED
@@ -142,6 +142,8 @@ export interface CollectedTestCase {
142
142
  consoleLogs?: unknown;
143
143
  /** Parsed from `piwi-dashboard-aria-snapshot` attachment. */
144
144
  ariaSnapshot?: string;
145
+ /** Parsed from `piwi-dashboard-locators` attachment. */
146
+ locatorSnapshots?: import('./locator-healing.js').LocatorSnapshot[];
145
147
  }
146
148
  /**
147
149
  * The per-case wire shape that `toWireTestCase` produces and the server
@@ -176,6 +178,8 @@ export interface WireTestCase {
176
178
  /** Step-event discriminant (only for `step-begin`/`step-end` events). */
177
179
  stepCategory?: string | null;
178
180
  parentTitle?: string | null;
181
+ /** Per-element locator snapshots with ranked alternatives (transient — not stored per-run). */
182
+ locatorSnapshots?: unknown;
179
183
  }
180
184
  export interface BeginStreamEvent {
181
185
  type: 'begin';
@@ -211,6 +215,7 @@ export interface CompleteStreamEvent {
211
215
  consoleLogs?: unknown;
212
216
  ariaSnapshot?: unknown;
213
217
  testSource?: string | null;
218
+ locatorSnapshots?: unknown;
214
219
  }
215
220
  export interface StepBeginStreamEvent {
216
221
  type: 'step-begin';
@@ -17,6 +17,7 @@ export interface RunPayload {
17
17
  totalTests: number;
18
18
  passedTests: number;
19
19
  failedTests: number;
20
+ timedOutTests?: number;
20
21
  skippedTests: number;
21
22
  /** Tests that never executed (cut short by `maxFailures` or a serial-group failure) */
22
23
  didNotRunTests?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Playwright reporter for sending test results to Piwi Dashboard",
5
5
  "url": "https://github.com/PiwiTests/platform",
6
6
  "homepage": "https://piwitests.github.io",