squally-reporter 0.5.1 → 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/README.md CHANGED
@@ -110,6 +110,13 @@ If Squally is unreachable partway through, results already sent stay in the
110
110
  dashboard; only what could not be delivered is lost, and your test run's exit
111
111
  code is never affected either way.
112
112
 
113
+ **Only actual test runs appear.** A run shows up in Squally once your first
114
+ test starts, so commands that never run a test create nothing: `--list`,
115
+ a `globalSetup` or `webServer` that fails to start, a `--grep` that matches
116
+ nothing. A run that *does* execute and legitimately contains no tests — an
117
+ empty shard, `--only-changed` with no changed test file, `--pass-with-no-tests`
118
+ — is still reported, as a run with zero results.
119
+
113
120
  ## Sharding
114
121
 
115
122
  **Handled automatically - no extra setup needed.** Each shard is still a
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,13 +78,51 @@ 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;
111
+ private runStarted;
72
112
  private runIdPromise;
73
113
  private runId;
74
114
  constructor(options?: SquallyReporterOptions);
75
115
  onBegin(config: FullConfig, suite: Suite): void;
116
+ /**
117
+ * Create the run server-side. Idempotent, and deliberately not called from
118
+ * onBegin.
119
+ *
120
+ * The flush timer is armed here rather than in onBegin for the same reason:
121
+ * nothing can be in the buffer before a test has ended, and a timer that can
122
+ * only ever fire on an empty buffer is a timer with nothing to do.
123
+ */
124
+ private startRun;
125
+ onTestBegin(): void;
76
126
  private resolveRunConfig;
77
127
  onTestEnd(test: TestCase, result: TestResult): void;
78
128
  onEnd: (result: FullResult) => Promise<void>;
@@ -105,6 +155,7 @@ export declare const __internal: {
105
155
  mapErrors: typeof mapErrors;
106
156
  parseRetryAfterMs: typeof parseRetryAfterMs;
107
157
  computeBackoffMs: typeof computeBackoffMs;
158
+ flushIntervalFor: typeof flushIntervalFor;
108
159
  attemptQualifiesForArtifacts: typeof attemptQualifiesForArtifacts;
109
160
  artifactKey: typeof artifactKey;
110
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
@@ -295,6 +336,39 @@ function detectAuthorName() {
295
336
  // the same escape-hatch pattern this file already uses for
296
337
  // SAME_REPO_AS_APP/TESTED_REVISION below.
297
338
  //
339
+ // Is this invocation `playwright test --list` - "collect all the tests and
340
+ // report them, but do not run" (the flag's own help text)?
341
+ //
342
+ // WHY THIS IS AN OPTIMISATION AND NOT THE MECHANISM. A list run is a customer
343
+ // enumerating tests: in CI, in an editor, or from this repo's own AGENTS.md,
344
+ // which cites `--list` as a check. It is not a test run, and until 0.5.2 the
345
+ // reporter created one anyway - a completed run with zero results, consuming a
346
+ // run number and a slot in the customer's monthly quota.
347
+ //
348
+ // MEASURED, both directions, on 1.62.1 and on 1.30.0 (this package's peer
349
+ // floor):
350
+ // - `config.argv` is documented ("Snapshot of process.argv captured in the
351
+ // runner process") and carries `--list` on 1.62.1. On 1.30.0 the field does
352
+ // not exist at all - not in the types, not on the object. So it cannot be
353
+ // the mechanism for a package whose peer range starts at 1.30.
354
+ // - `process.argv` carries `--list` on BOTH, because a reporter is loaded in
355
+ // the runner process, which is the CLI process. Hence the fallback.
356
+ // - There is no `listOnly` on FullConfig on either version. The only other
357
+ // difference between a list run and a real one is behavioural: onBegin
358
+ // fires with the suite fully populated, and then no test ever begins.
359
+ //
360
+ // That behavioural fingerprint is what the run-creation deferral in the class
361
+ // below relies on, and it is why THIS function is allowed to be imperfect: an
362
+ // invocation that lists tests without `--list` on its argv - an IDE driving the
363
+ // runner through its own entry point, say - is still covered, because it also
364
+ // never begins a test. What this function buys on top is silence: a list run
365
+ // makes no network call at all, not even the rules GET, which matters when an
366
+ // editor lists tests on every keystroke-triggered reload.
367
+ function isListOnlyInvocation(config) {
368
+ const configArgv = config.argv;
369
+ const argv = Array.isArray(configArgv) ? configArgv : process.argv;
370
+ return argv.some((arg) => arg === "--list");
371
+ }
298
372
  // env and generateFallback are parameters, not process.env/randomUUID read
299
373
  // directly, so this is unit-testable without mocking global state.
300
374
  function detectCiRunId(env, generateFallback) {
@@ -739,6 +813,33 @@ class SquallyReporter {
739
813
  this.projectName = null;
740
814
  this.buffer = [];
741
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;
742
843
  // True once nothing more will ever be sent for this run - a permanent
743
844
  // give-up (402/404/409 on append, or onBegin never getting a runId at
744
845
  // all). onTestEnd stops collecting further results/artifacts once this is
@@ -746,9 +847,15 @@ class SquallyReporter {
746
847
  this.sendingDisabled = false;
747
848
  this.flushTimerHandle = null;
748
849
  this.inFlightFlushes = new Set();
749
- // Resolves to the runId once onBegin succeeds, or to null on any permanent
850
+ // Has the run been created server-side? Set by startRun(), which fires on
851
+ // the first test rather than in onBegin (0.5.2). While this is false, this
852
+ // process has sent nothing that could become a run in the dashboard.
853
+ this.runStarted = false;
854
+ // Resolves to the runId once beginRun() succeeds, or to null on any permanent
750
855
  // failure (never rejects - every failure path here is a warning, not an
751
- // exception, same principle as the rest of this file).
856
+ // exception, same principle as the rest of this file). Stays the initial
857
+ // resolved-null promise until startRun() replaces it, which is what makes
858
+ // flush() a no-op for an invocation that never ran a test.
752
859
  this.runIdPromise = Promise.resolve(null);
753
860
  // Set once runIdPromise resolves to a real id - read by onEnd's artifact
754
861
  // upload step, which needs a plain value rather than a promise it would
@@ -814,6 +921,15 @@ class SquallyReporter {
814
921
  onBegin(config, suite) {
815
922
  if (!this.enabled)
816
923
  return;
924
+ // A list run is not a run. Disabling the reporter outright is what makes
925
+ // every later hook - and onEnd's dispatcher.close() - a no-op, so nothing
926
+ // below this line has to know about the case. Silent on purpose: `--list`
927
+ // output is parsed by tooling, and a warning printed on every editor
928
+ // refresh would be noise about a decision the customer did not make.
929
+ if (isListOnlyInvocation(config)) {
930
+ this.enabled = false;
931
+ return;
932
+ }
817
933
  this.startedAt = new Date();
818
934
  this.dispatcher = new undici_1.Agent({
819
935
  keepAliveTimeout: 4000,
@@ -837,10 +953,44 @@ class SquallyReporter {
837
953
  this.runTags =
838
954
  tags.length > 0 ? tags.slice(0, MAX_TAGS).map((tag) => truncate(tag, V2_FIELD_LIMITS.tag)) : undefined;
839
955
  this.runConfig = this.resolveRunConfig(config);
840
- this.runIdPromise = this.beginRun();
956
+ // NO beginRun() HERE ANY MORE (0.5.2). Until 0.5.1 this line created the
957
+ // run server-side the moment Playwright started, which meant every
958
+ // invocation that never executed a test still produced one - measured
959
+ // against a logging mock: `--list`, a failing globalSetup and a webServer
960
+ // that will not start all reach onBegin and all created a completed,
961
+ // empty run. The run is now created by startRun() when the first test
962
+ // actually begins, and by drainAndComplete() for the one case where a run
963
+ // that executed nothing must still be reported (see there).
964
+ //
965
+ // The rules GET stays here: it has to be in flight BEFORE tests run to be
966
+ // of any use, it creates nothing, and it costs no quota.
841
967
  void this.fetchRules();
968
+ }
969
+ /**
970
+ * Create the run server-side. Idempotent, and deliberately not called from
971
+ * onBegin.
972
+ *
973
+ * The flush timer is armed here rather than in onBegin for the same reason:
974
+ * nothing can be in the buffer before a test has ended, and a timer that can
975
+ * only ever fire on an empty buffer is a timer with nothing to do.
976
+ */
977
+ startRun() {
978
+ if (this.runStarted || !this.enabled)
979
+ return;
980
+ this.runStarted = true;
981
+ this.runIdPromise = this.beginRun();
842
982
  this.armFlushTimer();
843
983
  }
984
+ // The trigger. onTestBegin fires for every test Playwright actually
985
+ // executes, INCLUDING skipped ones - measured, both the statically skipped
986
+ // (`test.skip("name", fn)`) and the conditionally skipped
987
+ // (`test.skip(true, "reason")`) form - so a suite whose tests are all
988
+ // skipped is still a run, and still reported.
989
+ onTestBegin() {
990
+ if (!this.enabled)
991
+ return;
992
+ this.startRun();
993
+ }
844
994
  // config.browsers ({name, version}[]) is deliberately OMITTED here, not
845
995
  // sent as an empty/placeholder array. Getting REAL installed browser
846
996
  // versions would mean either launching each browser type just to query it
@@ -863,8 +1013,28 @@ class SquallyReporter {
863
1013
  };
864
1014
  }
865
1015
  onTestEnd(test, result) {
866
- 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)
867
1031
  return;
1032
+ // Belt and braces: onTestBegin has already done this for every test
1033
+ // Playwright executes, but a result arriving without its begin would
1034
+ // otherwise be buffered against a run that was never created and dropped
1035
+ // silently at onEnd. startRun() is idempotent, so the ordinary path pays
1036
+ // one boolean check.
1037
+ this.startRun();
868
1038
  // Truncated HERE, once, not when the payload is built: testName/filePath
869
1039
  // are Squally's test identity, and /api/artifacts/presign resolves each
870
1040
  // artifact back to a TestResult through exactly these two values plus
@@ -955,17 +1125,51 @@ class SquallyReporter {
955
1125
  // (deliberate divergence from v1) Once a runId exists, /complete is ALWAYS
956
1126
  // called - there is no "this run had zero results, skip reporting it
957
1127
  // entirely" special case, unlike v1 (`if (this.results.length === 0)
958
- // return;`, guarding the whole of onEnd there). That check cannot be
1128
+ // return;`, guarding the whole of onEnd there). ~~That check cannot be
959
1129
  // reproduced here: onBegin already created the run server-side, before a
960
- // single test could have told us the eventual count would be zero.
961
- // Skipping /complete at this point would not undo the run's existence - it
962
- // would leave an already-created run stuck in `running` until the timeout
963
- // sweep notices (currently up to 18 hours, see docs/status.md's run-timeout
964
- // entry). Completing it immediately with zero results is strictly better: a
965
- // short-lived empty run in the dashboard costs less than an
966
- // apparently-stuck one for the better part of a day.
1130
+ // single test could have told us the eventual count would be zero.~~
1131
+ // CORRECTED 0.5.2: onBegin no longer creates it, so the decision IS
1132
+ // available here - and it is taken by the block at the top of the method
1133
+ // below, which is a stricter version of v1's check. What stands unchanged is
1134
+ // the reasoning about a run that HAS been created: skipping /complete for it
1135
+ // would not undo its existence, it would leave it stuck in `running` until
1136
+ // the timeout sweep notices (up to 18 hours, see docs/status.md's run-timeout
1137
+ // entry).
967
1138
  async drainAndComplete(result) {
968
1139
  this.stopFlushTimer();
1140
+ // NOTHING RAN. Two cases that look identical from here and must not be
1141
+ // treated identically (0.5.2):
1142
+ //
1143
+ // 1. The suite the customer asked for was EMPTY, and Playwright ran it
1144
+ // to completion anyway. `--pass-with-no-tests`, `--only-changed` on a
1145
+ // commit that touched no test file, and - the one that matters most -
1146
+ // a shard with nothing in it, which happens whenever there are fewer
1147
+ // tests than shards. All three exit 0 with FullResult.status
1148
+ // "passed", measured. These MUST still be reported: a customer who
1149
+ // filters to a tag with no tests and sees nothing at all concludes
1150
+ // the reporter is broken, and an unreported empty shard is worse than
1151
+ // that - its group would never reach its shard total and would sit
1152
+ // incomplete forever (squally-app docs/shard-ui-spec.md section 5.5).
1153
+ //
1154
+ // 2. Playwright never got as far as running the suite: a globalSetup
1155
+ // that threw, a webServer that would not start, `--grep` matching
1156
+ // nothing without `--pass-with-no-tests`, `--last-failed` with
1157
+ // nothing to repeat. All measured: onBegin fires, an onError follows,
1158
+ // and FullResult.status is "failed" - the run ends in an error state
1159
+ // having executed nothing. Reporting these produced an empty run
1160
+ // whose only content was that it was empty, at the cost of a run
1161
+ // number and a quota slot.
1162
+ //
1163
+ // The line between them is FullResult.status, and that is the whole rule:
1164
+ // Squally reports a run when Playwright ran the suite to completion,
1165
+ // whatever the suite turned out to contain, and does not when Playwright
1166
+ // never got that far. `--list` never reaches here at all - onBegin
1167
+ // disables the reporter outright.
1168
+ if (!this.runStarted) {
1169
+ if (result.status !== "passed")
1170
+ return;
1171
+ this.startRun();
1172
+ }
969
1173
  const deadline = Date.now() + DRAIN_BUDGET_MS;
970
1174
  const runId = await withDeadline(this.runIdPromise, deadline);
971
1175
  if (runId === TIMED_OUT) {
@@ -992,7 +1196,11 @@ class SquallyReporter {
992
1196
  const handle = setTimeout(() => {
993
1197
  this.triggerFlush();
994
1198
  this.armFlushTimer();
995
- }, 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));
996
1204
  handle.unref();
997
1205
  this.flushTimerHandle = handle;
998
1206
  }
@@ -1132,6 +1340,9 @@ class SquallyReporter {
1132
1340
  shardIndex: this.shardIndex,
1133
1341
  status,
1134
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,
1135
1346
  };
1136
1347
  const outcome = await this.requestJson("POST", `${this.apiUrl}/api/ingest/v2/runs/${runId}/complete`, payload);
1137
1348
  if (outcome.kind !== "ok") {
@@ -1393,6 +1604,7 @@ exports.__internal = {
1393
1604
  mapErrors,
1394
1605
  parseRetryAfterMs,
1395
1606
  computeBackoffMs,
1607
+ flushIntervalFor,
1396
1608
  attemptQualifiesForArtifacts,
1397
1609
  artifactKey,
1398
1610
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squally-reporter",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Playwright reporter that sends test results to your Squally dashboard.",
5
5
  "author": "Squally <squally.dev@gmail.com>",
6
6
  "keywords": [
@@ -24,7 +24,8 @@
24
24
  "prepare": "npm run build",
25
25
  "test:unit": "node --test \"test/unit/**/*.test.js\"",
26
26
  "test:exit-code": "node test/exit-code/run.mjs",
27
- "test": "npm run build && npm run test:unit && npm run test:exit-code"
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"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "@playwright/test": ">=1.30.0"