squally-reporter 0.5.2 → 0.6.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.d.ts CHANGED
@@ -29,6 +29,18 @@ interface AppendResultEntry {
29
29
  type ArtifactType = "screenshot" | "trace";
30
30
  declare function truncate(value: string, max: number): string;
31
31
  declare function truncateOrNull(value: string | null, max: number): string | null;
32
+ /**
33
+ * This shard's flush interval: the base cadence stretched by how many shards
34
+ * share the rate budget with it.
35
+ *
36
+ * Only the TIMER is scaled. FLUSH_THRESHOLD_RESULTS is not, and that is
37
+ * deliberate: it is a latency bound on a fast suite, not a rate control, and
38
+ * stretching it would delay results without bounding the burst any better -
39
+ * the burst is bounded by how fast tests finish, which is not ours to set.
40
+ * The spec's acknowledged worst case (a very fast unit-style suite crossing 25
41
+ * faster than the interval) is therefore unchanged by this.
42
+ */
43
+ declare function flushIntervalFor(shardTotal: number | null): number;
32
44
  declare function mapStatus(status: TestResult["status"]): AppendResultEntry["status"];
33
45
  declare function detectCiRunId(env: NodeJS.ProcessEnv, generateFallback: () => string): string;
34
46
  interface ProjectSuiteLike {
@@ -66,6 +78,33 @@ export default class SquallyReporter implements Reporter {
66
78
  private runConfig;
67
79
  private buffer;
68
80
  private artifacts;
81
+ /**
82
+ * Every result this shard produced, counted where it is produced (0.6.0).
83
+ *
84
+ * SIX PATHS LOSE RESULTS, and this counter exists because five of them are
85
+ * invisible afterwards and the sixth cannot be counted at all where it
86
+ * happens:
87
+ *
88
+ * 1. a batch dropped after its retries are spent (429, 5xx, network,
89
+ * unparseable response) - sendBatch's default branch
90
+ * 2. a batch dropped as malformed (400), which is not retried
91
+ * 3. the whole unsent buffer discarded by giveUp() on 402/404/409
92
+ * 4. every result produced AFTER a give-up - onTestEnd returns before the
93
+ * buffer, so there is nothing to count at the point of loss
94
+ * 5. batches still in flight when the drain budget expires at onEnd; the
95
+ * process then completes the run and exits out from under them
96
+ * 6. onBegin never getting a runId, so nothing was ever sendable
97
+ *
98
+ * A drop counter would have to be correct in all six places forever, and
99
+ * path 4 has no place to put it. Counting production instead needs one
100
+ * increment in one function, and it makes loss visible even on a path this
101
+ * reporter does not yet know about - the server compares this against what
102
+ * it actually stored.
103
+ *
104
+ * Per shard, for this shard's runId: each shard reports its own production
105
+ * against its own row, so no group arithmetic is needed on either side.
106
+ */
107
+ private resultsProduced;
69
108
  private sendingDisabled;
70
109
  private flushTimerHandle;
71
110
  private inFlightFlushes;
@@ -116,6 +155,7 @@ export declare const __internal: {
116
155
  mapErrors: typeof mapErrors;
117
156
  parseRetryAfterMs: typeof parseRetryAfterMs;
118
157
  computeBackoffMs: typeof computeBackoffMs;
158
+ flushIntervalFor: typeof flushIntervalFor;
119
159
  attemptQualifiesForArtifacts: typeof attemptQualifiesForArtifacts;
120
160
  artifactKey: typeof artifactKey;
121
161
  };
package/dist/index.js CHANGED
@@ -138,6 +138,47 @@ const RULES_REQUEST_TIMEOUT_MS = 5000;
138
138
  // API key): 60 s / 2 s = 30 requests/minute, half of 60.
139
139
  const FLUSH_THRESHOLD_RESULTS = 25;
140
140
  const FLUSH_INTERVAL_MS = 2000;
141
+ // Scaled by the shard count (0.6.0), because the budget the 2 s above was
142
+ // derived from is NOT per reporter - it is per API key, and every shard of a
143
+ // run carries the same key. The firewall counts `header:authorization`, so
144
+ // eight shards each flushing every 2 s produce 8 x 30 = 240 requests/minute
145
+ // against a 60/minute rule. The "half of 60" margin the constant above claims
146
+ // was computed for a single reporter and silently stopped holding the day
147
+ // sharding arrived.
148
+ //
149
+ // interval x shards keeps the AGGREGATE rate at 30/minute for any shard count:
150
+ // S shards x (60 s / 2S s) = 30. The live view does not get slower for it
151
+ // either - it shows the RUN, and a run made of S shards receives an update
152
+ // from one shard or another every 2 s on average regardless of S.
153
+ //
154
+ // CAPPED, and the cap is about crash exposure rather than rate. Everything
155
+ // buffered is lost if the runner is killed (CI cancellation, OOM), so the
156
+ // interval is also the size of the window at risk. 30 s is the same figure
157
+ // RATE_LIMIT_BACKOFF_CAP_MS uses for "the longest we ever wait", and it keeps
158
+ // the aggregate inside the budget up to 30 shards (S x 60/30 <= 60), which
159
+ // covers the 4-to-16 range the app's own shard documentation describes with
160
+ // room to spare.
161
+ //
162
+ // Above 30 shards the aggregate creeps over the rule again. That is accepted:
163
+ // it is outside the documented range, the retry path already handles a 429,
164
+ // and since 0.6.0 any batch actually lost to it is visible in the
165
+ // resultsProduced diff rather than silent.
166
+ const FLUSH_INTERVAL_CAP_MS = 30000;
167
+ /**
168
+ * This shard's flush interval: the base cadence stretched by how many shards
169
+ * share the rate budget with it.
170
+ *
171
+ * Only the TIMER is scaled. FLUSH_THRESHOLD_RESULTS is not, and that is
172
+ * deliberate: it is a latency bound on a fast suite, not a rate control, and
173
+ * stretching it would delay results without bounding the burst any better -
174
+ * the burst is bounded by how fast tests finish, which is not ours to set.
175
+ * The spec's acknowledged worst case (a very fast unit-style suite crossing 25
176
+ * faster than the interval) is therefore unchanged by this.
177
+ */
178
+ function flushIntervalFor(shardTotal) {
179
+ const shards = Math.max(1, shardTotal ?? 1);
180
+ return Math.min(FLUSH_INTERVAL_CAP_MS, FLUSH_INTERVAL_MS * shards);
181
+ }
141
182
  // --- Retry policy (spec section 3.2) ----------------------------------------
142
183
  //
143
184
  // Two independent policies, both applied by requestJson() below via separate
@@ -772,6 +813,33 @@ class SquallyReporter {
772
813
  this.projectName = null;
773
814
  this.buffer = [];
774
815
  this.artifacts = [];
816
+ /**
817
+ * Every result this shard produced, counted where it is produced (0.6.0).
818
+ *
819
+ * SIX PATHS LOSE RESULTS, and this counter exists because five of them are
820
+ * invisible afterwards and the sixth cannot be counted at all where it
821
+ * happens:
822
+ *
823
+ * 1. a batch dropped after its retries are spent (429, 5xx, network,
824
+ * unparseable response) - sendBatch's default branch
825
+ * 2. a batch dropped as malformed (400), which is not retried
826
+ * 3. the whole unsent buffer discarded by giveUp() on 402/404/409
827
+ * 4. every result produced AFTER a give-up - onTestEnd returns before the
828
+ * buffer, so there is nothing to count at the point of loss
829
+ * 5. batches still in flight when the drain budget expires at onEnd; the
830
+ * process then completes the run and exits out from under them
831
+ * 6. onBegin never getting a runId, so nothing was ever sendable
832
+ *
833
+ * A drop counter would have to be correct in all six places forever, and
834
+ * path 4 has no place to put it. Counting production instead needs one
835
+ * increment in one function, and it makes loss visible even on a path this
836
+ * reporter does not yet know about - the server compares this against what
837
+ * it actually stored.
838
+ *
839
+ * Per shard, for this shard's runId: each shard reports its own production
840
+ * against its own row, so no group arithmetic is needed on either side.
841
+ */
842
+ this.resultsProduced = 0;
775
843
  // True once nothing more will ever be sent for this run - a permanent
776
844
  // give-up (402/404/409 on append, or onBegin never getting a runId at
777
845
  // all). onTestEnd stops collecting further results/artifacts once this is
@@ -945,7 +1013,21 @@ class SquallyReporter {
945
1013
  };
946
1014
  }
947
1015
  onTestEnd(test, result) {
948
- if (!this.enabled || this.sendingDisabled)
1016
+ // Two guards, deliberately split by the counter between them.
1017
+ //
1018
+ // `enabled` is false when there is nothing to report to at all (no URL or
1019
+ // key, or `--list`): no run exists, no /complete will be sent, and there
1020
+ // is nothing for a count to be compared against.
1021
+ //
1022
+ // `sendingDisabled` is different - a run DOES exist server-side, it will
1023
+ // be completed, and every result arriving after the give-up is lost. That
1024
+ // is loss path 4, and counting BEFORE this return is the only place it can
1025
+ // be seen. Moving the increment below the return would make the largest
1026
+ // loss path of the six silently report zero.
1027
+ if (!this.enabled)
1028
+ return;
1029
+ this.resultsProduced += 1;
1030
+ if (this.sendingDisabled)
949
1031
  return;
950
1032
  // Belt and braces: onTestBegin has already done this for every test
951
1033
  // Playwright executes, but a result arriving without its begin would
@@ -1114,7 +1196,11 @@ class SquallyReporter {
1114
1196
  const handle = setTimeout(() => {
1115
1197
  this.triggerFlush();
1116
1198
  this.armFlushTimer();
1117
- }, FLUSH_INTERVAL_MS);
1199
+ // Read per arming rather than cached at onBegin: shardTotal is known
1200
+ // before the first arming (config.shard.total, set in onBegin), so this
1201
+ // is the same value every time - reading it here keeps the timer honest
1202
+ // if that ever stops being true.
1203
+ }, flushIntervalFor(this.shardTotal));
1118
1204
  handle.unref();
1119
1205
  this.flushTimerHandle = handle;
1120
1206
  }
@@ -1254,6 +1340,9 @@ class SquallyReporter {
1254
1340
  shardIndex: this.shardIndex,
1255
1341
  status,
1256
1342
  finishedAt: new Date().toISOString(),
1343
+ // Read at the last possible moment: every loss path has already happened
1344
+ // by the time /complete is sent, so this number is final.
1345
+ resultsProduced: this.resultsProduced,
1257
1346
  };
1258
1347
  const outcome = await this.requestJson("POST", `${this.apiUrl}/api/ingest/v2/runs/${runId}/complete`, payload);
1259
1348
  if (outcome.kind !== "ok") {
@@ -1515,6 +1604,7 @@ exports.__internal = {
1515
1604
  mapErrors,
1516
1605
  parseRetryAfterMs,
1517
1606
  computeBackoffMs,
1607
+ flushIntervalFor,
1518
1608
  attemptQualifiesForArtifacts,
1519
1609
  artifactKey,
1520
1610
  };
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
- {
2
- "name": "squally-reporter",
3
- "version": "0.5.2",
4
- "description": "Playwright reporter that sends test results to your Squally dashboard.",
5
- "author": "Squally <squally.dev@gmail.com>",
6
- "keywords": [
7
- "playwright",
8
- "playwright-reporter",
9
- "flaky-tests",
10
- "test-results",
11
- "ci"
12
- ],
13
- "main": "dist/index.js",
14
- "types": "dist/index.d.ts",
15
- "files": [
16
- "dist"
17
- ],
18
- "engines": {
19
- "node": ">=22.19.0"
20
- },
21
- "scripts": {
22
- "build": "tsc",
23
- "prepublishOnly": "npm run build",
24
- "prepare": "npm run build",
25
- "test:unit": "node --test \"test/unit/**/*.test.js\"",
26
- "test:exit-code": "node test/exit-code/run.mjs",
27
- "test:empty-runs": "node test/empty-runs/run.mjs",
28
- "test": "npm run build && npm run test:unit && npm run test:exit-code && npm run test:empty-runs"
29
- },
30
- "peerDependencies": {
31
- "@playwright/test": ">=1.30.0"
32
- },
33
- "devDependencies": {
34
- "@playwright/test": "^1.48.0",
35
- "@types/node": "^20.14.0",
36
- "typescript": "^5.6.0"
37
- },
38
- "license": "MIT",
39
- "dependencies": {
40
- "undici": "^8.10.0"
41
- }
42
- }
1
+ {
2
+ "name": "squally-reporter",
3
+ "version": "0.6.0",
4
+ "description": "Playwright reporter that sends test results to your Squally dashboard.",
5
+ "author": "Squally <squally.dev@gmail.com>",
6
+ "keywords": [
7
+ "playwright",
8
+ "playwright-reporter",
9
+ "flaky-tests",
10
+ "test-results",
11
+ "ci"
12
+ ],
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22.19.0"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "prepublishOnly": "npm run build",
24
+ "prepare": "npm run build",
25
+ "test:unit": "node --test \"test/unit/**/*.test.js\"",
26
+ "test:exit-code": "node test/exit-code/run.mjs",
27
+ "test:empty-runs": "node test/empty-runs/run.mjs",
28
+ "test": "npm run build && npm run test:unit && npm run test:exit-code && npm run test:empty-runs"
29
+ },
30
+ "peerDependencies": {
31
+ "@playwright/test": ">=1.30.0"
32
+ },
33
+ "devDependencies": {
34
+ "@playwright/test": "^1.48.0",
35
+ "@types/node": "^20.14.0",
36
+ "typescript": "^5.6.0"
37
+ },
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "undici": "^8.10.0"
41
+ }
42
+ }