@setsquare/review-sdk 0.0.1

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.
Files changed (181) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +57 -0
  3. package/README.md +338 -0
  4. package/THIRD-PARTY-NOTICES.md +61 -0
  5. package/dist/autoscan.d.ts +25 -0
  6. package/dist/autoscan.d.ts.map +1 -0
  7. package/dist/autoscan.js +332 -0
  8. package/dist/autoscan.js.map +1 -0
  9. package/dist/axe.d.ts +96 -0
  10. package/dist/axe.d.ts.map +1 -0
  11. package/dist/axe.js +210 -0
  12. package/dist/axe.js.map +1 -0
  13. package/dist/bin/setsquare.d.ts +3 -0
  14. package/dist/bin/setsquare.d.ts.map +1 -0
  15. package/dist/bin/setsquare.js +21 -0
  16. package/dist/bin/setsquare.js.map +1 -0
  17. package/dist/brand.d.ts +84 -0
  18. package/dist/brand.d.ts.map +1 -0
  19. package/dist/brand.js +335 -0
  20. package/dist/brand.js.map +1 -0
  21. package/dist/checks/dynamic.d.ts +57 -0
  22. package/dist/checks/dynamic.d.ts.map +1 -0
  23. package/dist/checks/dynamic.js +184 -0
  24. package/dist/checks/dynamic.js.map +1 -0
  25. package/dist/checks/rule-pack.d.ts +34 -0
  26. package/dist/checks/rule-pack.d.ts.map +1 -0
  27. package/dist/checks/rule-pack.js +270 -0
  28. package/dist/checks/rule-pack.js.map +1 -0
  29. package/dist/checks/versions.d.ts +28 -0
  30. package/dist/checks/versions.d.ts.map +1 -0
  31. package/dist/checks/versions.js +45 -0
  32. package/dist/checks/versions.js.map +1 -0
  33. package/dist/ci.d.ts +37 -0
  34. package/dist/ci.d.ts.map +1 -0
  35. package/dist/ci.js +199 -0
  36. package/dist/ci.js.map +1 -0
  37. package/dist/contract.d.ts +334 -0
  38. package/dist/contract.d.ts.map +1 -0
  39. package/dist/contract.js +35 -0
  40. package/dist/contract.js.map +1 -0
  41. package/dist/dedupe.d.ts +79 -0
  42. package/dist/dedupe.d.ts.map +1 -0
  43. package/dist/dedupe.js +102 -0
  44. package/dist/dedupe.js.map +1 -0
  45. package/dist/document.d.ts +29 -0
  46. package/dist/document.d.ts.map +1 -0
  47. package/dist/document.js +94 -0
  48. package/dist/document.js.map +1 -0
  49. package/dist/envelope.d.ts +124 -0
  50. package/dist/envelope.d.ts.map +1 -0
  51. package/dist/envelope.js +337 -0
  52. package/dist/envelope.js.map +1 -0
  53. package/dist/escape.d.ts +14 -0
  54. package/dist/escape.d.ts.map +1 -0
  55. package/dist/escape.js +20 -0
  56. package/dist/escape.js.map +1 -0
  57. package/dist/findings.d.ts +47 -0
  58. package/dist/findings.d.ts.map +1 -0
  59. package/dist/findings.js +229 -0
  60. package/dist/findings.js.map +1 -0
  61. package/dist/fixture.d.ts +7 -0
  62. package/dist/fixture.d.ts.map +1 -0
  63. package/dist/fixture.js +237 -0
  64. package/dist/fixture.js.map +1 -0
  65. package/dist/group.d.ts +214 -0
  66. package/dist/group.d.ts.map +1 -0
  67. package/dist/group.js +709 -0
  68. package/dist/group.js.map +1 -0
  69. package/dist/html-report.d.ts +68 -0
  70. package/dist/html-report.d.ts.map +1 -0
  71. package/dist/html-report.js +649 -0
  72. package/dist/html-report.js.map +1 -0
  73. package/dist/human.d.ts +65 -0
  74. package/dist/human.d.ts.map +1 -0
  75. package/dist/human.js +111 -0
  76. package/dist/human.js.map +1 -0
  77. package/dist/index.d.ts +34 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +33 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/infer.d.ts +65 -0
  82. package/dist/infer.d.ts.map +1 -0
  83. package/dist/infer.js +67 -0
  84. package/dist/infer.js.map +1 -0
  85. package/dist/insights.d.ts +140 -0
  86. package/dist/insights.d.ts.map +1 -0
  87. package/dist/insights.js +483 -0
  88. package/dist/insights.js.map +1 -0
  89. package/dist/install.d.ts +8 -0
  90. package/dist/install.d.ts.map +1 -0
  91. package/dist/install.js +114 -0
  92. package/dist/install.js.map +1 -0
  93. package/dist/links.d.ts +45 -0
  94. package/dist/links.d.ts.map +1 -0
  95. package/dist/links.js +45 -0
  96. package/dist/links.js.map +1 -0
  97. package/dist/mask.d.ts +85 -0
  98. package/dist/mask.d.ts.map +1 -0
  99. package/dist/mask.js +98 -0
  100. package/dist/mask.js.map +1 -0
  101. package/dist/observe.d.ts +51 -0
  102. package/dist/observe.d.ts.map +1 -0
  103. package/dist/observe.js +233 -0
  104. package/dist/observe.js.map +1 -0
  105. package/dist/options.d.ts +249 -0
  106. package/dist/options.d.ts.map +1 -0
  107. package/dist/options.js +185 -0
  108. package/dist/options.js.map +1 -0
  109. package/dist/output.d.ts +113 -0
  110. package/dist/output.d.ts.map +1 -0
  111. package/dist/output.js +231 -0
  112. package/dist/output.js.map +1 -0
  113. package/dist/perf.d.ts +11 -0
  114. package/dist/perf.d.ts.map +1 -0
  115. package/dist/perf.js +150 -0
  116. package/dist/perf.js.map +1 -0
  117. package/dist/reading-level.d.ts +73 -0
  118. package/dist/reading-level.d.ts.map +1 -0
  119. package/dist/reading-level.js +189 -0
  120. package/dist/reading-level.js.map +1 -0
  121. package/dist/reporter.d.ts +94 -0
  122. package/dist/reporter.d.ts.map +1 -0
  123. package/dist/reporter.js +709 -0
  124. package/dist/reporter.js.map +1 -0
  125. package/dist/rules.d.ts +55 -0
  126. package/dist/rules.d.ts.map +1 -0
  127. package/dist/rules.js +77 -0
  128. package/dist/rules.js.map +1 -0
  129. package/dist/runtime.d.ts +32 -0
  130. package/dist/runtime.d.ts.map +1 -0
  131. package/dist/runtime.js +67 -0
  132. package/dist/runtime.js.map +1 -0
  133. package/dist/sanitise.d.ts +116 -0
  134. package/dist/sanitise.d.ts.map +1 -0
  135. package/dist/sanitise.js +247 -0
  136. package/dist/sanitise.js.map +1 -0
  137. package/dist/scan.d.ts +60 -0
  138. package/dist/scan.d.ts.map +1 -0
  139. package/dist/scan.js +246 -0
  140. package/dist/scan.js.map +1 -0
  141. package/dist/screens.d.ts +98 -0
  142. package/dist/screens.d.ts.map +1 -0
  143. package/dist/screens.js +301 -0
  144. package/dist/screens.js.map +1 -0
  145. package/dist/settle.d.ts +65 -0
  146. package/dist/settle.d.ts.map +1 -0
  147. package/dist/settle.js +162 -0
  148. package/dist/settle.js.map +1 -0
  149. package/dist/shots.d.ts +109 -0
  150. package/dist/shots.d.ts.map +1 -0
  151. package/dist/shots.js +113 -0
  152. package/dist/shots.js.map +1 -0
  153. package/dist/style.d.ts +47 -0
  154. package/dist/style.d.ts.map +1 -0
  155. package/dist/style.js +154 -0
  156. package/dist/style.js.map +1 -0
  157. package/dist/suite.d.ts +218 -0
  158. package/dist/suite.d.ts.map +1 -0
  159. package/dist/suite.js +219 -0
  160. package/dist/suite.js.map +1 -0
  161. package/dist/summary.d.ts +268 -0
  162. package/dist/summary.d.ts.map +1 -0
  163. package/dist/summary.js +1308 -0
  164. package/dist/summary.js.map +1 -0
  165. package/dist/unscannable.d.ts +75 -0
  166. package/dist/unscannable.d.ts.map +1 -0
  167. package/dist/unscannable.js +111 -0
  168. package/dist/unscannable.js.map +1 -0
  169. package/dist/versions.d.ts +13 -0
  170. package/dist/versions.d.ts.map +1 -0
  171. package/dist/versions.js +39 -0
  172. package/dist/versions.js.map +1 -0
  173. package/dist/wcag-map.d.ts +19 -0
  174. package/dist/wcag-map.d.ts.map +1 -0
  175. package/dist/wcag-map.js +52 -0
  176. package/dist/wcag-map.js.map +1 -0
  177. package/dist/with-setsquare.d.ts +26 -0
  178. package/dist/with-setsquare.d.ts.map +1 -0
  179. package/dist/with-setsquare.js +70 -0
  180. package/dist/with-setsquare.js.map +1 -0
  181. package/package.json +81 -0
package/dist/group.js ADDED
@@ -0,0 +1,709 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, rm, stat, unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { buildEnvelope, UNKNOWN } from './envelope.js';
5
+ import { resolvePersona, resolveTheme, } from './infer.js';
6
+ import { pruneRuns, runDirName, writeFileAtomic, writeRunInto } from './output.js';
7
+ import { localFacts } from './suite.js';
8
+ import { toUrlPattern } from './sanitise.js';
9
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
10
+ /**
11
+ * Read the run-group contract from the environment.
12
+ *
13
+ * Environment rather than `withSetsquare` options, and necessarily so: the reporter
14
+ * is constructed by Playwright from JSON-safe config, and the group id must
15
+ * change per suite execution while the config file does not. Absent
16
+ * `SETSQUARE_RUN_ID`, everything in this module stays out of the way and a solo
17
+ * invocation behaves exactly as it always has.
18
+ */
19
+ export function readGroupContext(env = process.env) {
20
+ const raw = env['SETSQUARE_RUN_ID']?.trim();
21
+ if (!raw)
22
+ return undefined;
23
+ const warnings = [];
24
+ /*
25
+ * An id we cannot use as a directory name is HASHED, never rejected and never
26
+ * mangled positionally: two invocations handed the same odd id must land in
27
+ * the same directory, or the group silently splits — which is the exact
28
+ * failure this feature exists to prevent.
29
+ */
30
+ let id = raw;
31
+ if (!SAFE_ID.test(raw)) {
32
+ id = createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 16);
33
+ warnings.push(`SETSQUARE_RUN_ID contains characters unsafe in a directory name; using a stable hash (${id}) instead.`);
34
+ }
35
+ const lane = readLane(env, warnings);
36
+ const expectedParts = readExpectedParts(env, warnings);
37
+ return {
38
+ id,
39
+ dirName: `group-${id}`,
40
+ ...(lane ? { lane } : {}),
41
+ ...(expectedParts !== undefined ? { expectedParts } : {}),
42
+ warnings,
43
+ };
44
+ }
45
+ function readLane(env, warnings) {
46
+ const raw = env['SETSQUARE_LANE']?.trim();
47
+ if (!raw)
48
+ return undefined;
49
+ // Printable ASCII only, bounded. The lane ends up inside testIds and in
50
+ // rendered output, so it gets the same suspicion as any page-derived string.
51
+ const cleaned = raw
52
+ .replace(/[^\x20-\x7E]/g, '')
53
+ .slice(0, 64)
54
+ .trim();
55
+ if (cleaned !== raw) {
56
+ warnings.push('SETSQUARE_LANE contained characters that were removed or truncated.');
57
+ }
58
+ return cleaned || undefined;
59
+ }
60
+ function readExpectedParts(env, warnings) {
61
+ const raw = env['SETSQUARE_RUN_PARTS']?.trim();
62
+ if (!raw)
63
+ return undefined;
64
+ const value = Number(raw);
65
+ if (!Number.isInteger(value) || value < 1 || value > 1000) {
66
+ // A census we cannot read is a census that was never declared — reported,
67
+ // because a silently ignored declaration would let "complete" quietly mean
68
+ // less than the orchestrator believes it declared.
69
+ warnings.push(`SETSQUARE_RUN_PARTS is "${raw}", which is not a whole number of invocations; the declared census was ignored.`);
70
+ return undefined;
71
+ }
72
+ return value;
73
+ }
74
+ /* ------------------------------------------------------------------------- */
75
+ /* Parts: one invocation's complete contribution */
76
+ /* ------------------------------------------------------------------------- */
77
+ export const PART_VERSION = 1;
78
+ /**
79
+ * Minted at `onBegin`, from the invocation's start time — the begun marker and
80
+ * the part file must share a name, and only the start exists at marker time.
81
+ * The random suffix is for two invocations starting in the same millisecond,
82
+ * which concurrent lanes make entirely possible.
83
+ */
84
+ export function mintPartId(startedAt) {
85
+ return `${runDirName(startedAt)}-${randomBytes(4).toString('hex')}`;
86
+ }
87
+ function partsDir(groupDir) {
88
+ return join(groupDir, 'parts');
89
+ }
90
+ export async function writeBegunMarker(groupDir, marker) {
91
+ const dir = partsDir(groupDir);
92
+ await mkdir(dir, { recursive: true });
93
+ await writeFileAtomic(join(dir, `${marker.partId}.begun.json`), `${JSON.stringify(marker, null, 2)}\n`);
94
+ }
95
+ export async function writePart(groupDir, part) {
96
+ const dir = partsDir(groupDir);
97
+ await mkdir(dir, { recursive: true });
98
+ await writeFileAtomic(join(dir, `${part.partId}.json`), `${JSON.stringify(part, null, 2)}\n`);
99
+ }
100
+ /**
101
+ * Make an invocation's outcomes safe and self-contained before they touch disk.
102
+ *
103
+ * Two transformations, both load-bearing:
104
+ *
105
+ * **URLs are normalised to patterns NOW, not at merge.** Raw URLs carry
106
+ * identifiers and sometimes credentials — `/reset-password/<token>` is a real
107
+ * shape — and today they exist only transiently inside Playwright attachments.
108
+ * A part file is a durable artifact one careless `upload-artifact: .setsquare/**`
109
+ * away from leaving the machine, so the raw string must never be written to
110
+ * it. `toUrlPattern` is idempotent over its own output, so the merge applying
111
+ * it again is a no-op rather than a double-redaction.
112
+ *
113
+ * **Callback-based persona/theme resolution is applied now.** The customer's
114
+ * `persona`/`theme` functions live in this invocation's config and cannot be
115
+ * serialised; resolving here and recording the answer in `declared` produces
116
+ * the identical envelope value the merge would have produced with the callback
117
+ * in hand. Surfaces are left alone — their inference is map-based and the map
118
+ * travels in the part.
119
+ */
120
+ export function prepareOutcomesForPart(outcomes, infrastructureProjects, inference) {
121
+ return outcomes.map((outcome) => ({
122
+ ...outcome,
123
+ infrastructure: infrastructureProjects.has(outcome.project ?? ''),
124
+ records: outcome.records.map((record) => {
125
+ if (record.kind === 'observation') {
126
+ return {
127
+ ...record,
128
+ observed: {
129
+ ...record.observed,
130
+ navigations: record.observed.navigations.map((navigation) => ({
131
+ ...navigation,
132
+ url: toUrlPattern(navigation.url).pattern,
133
+ })),
134
+ },
135
+ };
136
+ }
137
+ const context = {
138
+ project: record.page.project,
139
+ urlPattern: toUrlPattern(record.page.url).pattern,
140
+ ...(record.page.observedTheme ? { observedTheme: record.page.observedTheme } : {}),
141
+ };
142
+ const theme = record.declared.theme ?? resolveTheme(context, inference);
143
+ const persona = record.declared.persona ?? resolvePersona(context, inference);
144
+ return {
145
+ ...record,
146
+ page: { ...record.page, url: context.urlPattern ?? '/' },
147
+ declared: {
148
+ ...record.declared,
149
+ ...(theme ? { theme } : {}),
150
+ ...(persona ? { persona } : {}),
151
+ },
152
+ };
153
+ }),
154
+ }));
155
+ }
156
+ /** The maps survive serialisation; the callbacks were already applied. */
157
+ export function serialisableInference(inference) {
158
+ return {
159
+ ...(inference.surfaces ? { surfaces: inference.surfaces } : {}),
160
+ ...(inference.personas ? { personas: inference.personas } : {}),
161
+ ...(inference.themes ? { themes: inference.themes } : {}),
162
+ };
163
+ }
164
+ async function readGroupState(groupDir) {
165
+ const dir = partsDir(groupDir);
166
+ let names;
167
+ try {
168
+ names = await readdir(dir);
169
+ }
170
+ catch {
171
+ return { parts: [], begun: [], corrupt: [], partFileNames: [] };
172
+ }
173
+ const begun = new Set();
174
+ const parts = [];
175
+ const corrupt = [];
176
+ const partFileNames = [];
177
+ for (const name of names.sort()) {
178
+ if (name.startsWith('.tmp-'))
179
+ continue;
180
+ if (name.endsWith('.begun.json')) {
181
+ begun.add(name.slice(0, -'.begun.json'.length));
182
+ continue;
183
+ }
184
+ if (!name.endsWith('.json'))
185
+ continue;
186
+ const partId = name.slice(0, -'.json'.length);
187
+ // A part is also proof its invocation began — a marker write that lost a
188
+ // race with a crash must not make the part's own evidence look uninvited.
189
+ begun.add(partId);
190
+ partFileNames.push(name);
191
+ try {
192
+ const parsed = JSON.parse(await readFile(join(dir, name), 'utf8'));
193
+ if (parsed.partVersion !== PART_VERSION || !Array.isArray(parsed.outcomes)) {
194
+ corrupt.push(partId);
195
+ continue;
196
+ }
197
+ parts.push(parsed);
198
+ }
199
+ catch {
200
+ // Unreadable is begun-but-not-merged, NEVER absent-and-fine: evidence we
201
+ // know existed and cannot read is missing evidence, and missing evidence
202
+ // must never look clean.
203
+ corrupt.push(partId);
204
+ }
205
+ }
206
+ parts.sort((a, b) => (a.partId < b.partId ? -1 : a.partId > b.partId ? 1 : 0));
207
+ return { parts, begun: [...begun].sort(), corrupt, partFileNames: partFileNames.sort() };
208
+ }
209
+ /* ------------------------------------------------------------------------- */
210
+ /* The merge */
211
+ /* ------------------------------------------------------------------------- */
212
+ /**
213
+ * Parts this much older than the newest part are another suite execution's
214
+ * leftovers, not this one's evidence. A reused SETSQUARE_RUN_ID (a branch name, a
215
+ * recycled build id) would otherwise merge yesterday's passing evidence over
216
+ * today's regression — wrong in the one direction this product must never be.
217
+ */
218
+ const STALE_PART_MS = 6 * 60 * 60 * 1000;
219
+ /** How long a merge lock can sit before it is presumed dead and taken over. */
220
+ const LOCK_STALE_MS = 60 * 1000;
221
+ /** How long to wait for a live lock before presuming the holder is stuck. */
222
+ const LOCK_WAIT_MS = 30 * 1000;
223
+ const MAX_MERGE_ROUNDS = 5;
224
+ export async function mergeGroup(inputs) {
225
+ const { groupDir } = inputs;
226
+ let result;
227
+ /*
228
+ * List → build → write must be a critical section, or this interleaving
229
+ * loses evidence while claiming not to: merger A lists {A}, stalls; B lists
230
+ * {A,B}, writes the full merge; A wakes, writes its stale {A}-only merge over
231
+ * it, and is killed before its convergence re-list. The lock closes that
232
+ * window; the re-list loop below handles parts that arrive between rounds.
233
+ */
234
+ await withMergeLock(groupDir, async () => {
235
+ for (let round = 0; round < MAX_MERGE_ROUNDS; round += 1) {
236
+ const state = await readGroupState(groupDir);
237
+ result = await writeMerged(inputs, state);
238
+ const after = await readGroupState(groupDir);
239
+ if (sameNames(state.partFileNames, after.partFileNames))
240
+ break;
241
+ }
242
+ });
243
+ await cleanupTmpFiles(partsDir(groupDir));
244
+ if (!result)
245
+ throw new Error('run-group merge produced no artifact');
246
+ return result;
247
+ }
248
+ function sameNames(a, b) {
249
+ return a.length === b.length && a.every((name, i) => name === b[i]);
250
+ }
251
+ async function withMergeLock(groupDir, fn) {
252
+ const lockDir = join(groupDir, 'merge.lock');
253
+ await mkdir(groupDir, { recursive: true });
254
+ const deadline = Date.now() + LOCK_WAIT_MS;
255
+ for (;;) {
256
+ try {
257
+ // mkdir is the one primitive that is atomically exclusive on every
258
+ // platform we run on — no lockfiles, no O_EXCL edge cases on NFS.
259
+ await mkdir(lockDir);
260
+ break;
261
+ }
262
+ catch (error) {
263
+ if (error.code !== 'EEXIST')
264
+ throw error;
265
+ const held = await stat(lockDir).catch(() => undefined);
266
+ const heldFor = held ? Date.now() - held.mtimeMs : Number.POSITIVE_INFINITY;
267
+ // A lock owner that died holding the lock must not deadlock every later
268
+ // invocation; one that is merely slow gets waited for, then presumed dead.
269
+ if (heldFor > LOCK_STALE_MS || Date.now() > deadline) {
270
+ await rm(lockDir, { recursive: true, force: true }).catch(() => { });
271
+ continue;
272
+ }
273
+ await new Promise((r) => setTimeout(r, 150));
274
+ }
275
+ }
276
+ try {
277
+ await fn();
278
+ }
279
+ finally {
280
+ await rm(lockDir, { recursive: true, force: true }).catch(() => { });
281
+ }
282
+ }
283
+ /** Crash leftovers from atomic writes; anyone's, once they are clearly dead. */
284
+ async function cleanupTmpFiles(dir) {
285
+ let names;
286
+ try {
287
+ names = await readdir(dir);
288
+ }
289
+ catch {
290
+ return;
291
+ }
292
+ for (const name of names) {
293
+ if (!name.startsWith('.tmp-'))
294
+ continue;
295
+ try {
296
+ const s = await stat(join(dir, name));
297
+ if (Date.now() - s.mtimeMs > 5 * 60 * 1000)
298
+ await unlink(join(dir, name));
299
+ }
300
+ catch {
301
+ // Already gone, or not ours to remove. Either way, not a problem.
302
+ }
303
+ }
304
+ }
305
+ /**
306
+ * Labels namespace each part's testIds in the merged envelope, because the
307
+ * same spec routinely runs in more than one lane and identity must not merge:
308
+ * a test that FAILED in the functional lane and passed in the a11y lane is two
309
+ * facts, and collapsing them by testId would render the failure as a flaky
310
+ * pass — the merge itself manufacturing a clean-looking claim.
311
+ *
312
+ * The lane is the label when it is unique; duplicated lanes (a retried lane,
313
+ * two shards of one lane) get a positional suffix so identity still cannot
314
+ * collide. Lane-less parts are labelled by position.
315
+ */
316
+ function partLabels(parts) {
317
+ const counts = new Map();
318
+ for (const part of parts) {
319
+ const lane = part.lane ?? '';
320
+ counts.set(lane, (counts.get(lane) ?? 0) + 1);
321
+ }
322
+ const seen = new Map();
323
+ const labels = new Map();
324
+ parts.forEach((part, index) => {
325
+ const lane = part.lane ?? '';
326
+ if (lane && counts.get(lane) === 1) {
327
+ labels.set(part.partId, lane);
328
+ return;
329
+ }
330
+ if (lane) {
331
+ const nth = (seen.get(lane) ?? 0) + 1;
332
+ seen.set(lane, nth);
333
+ labels.set(part.partId, `${lane}-${nth}`);
334
+ return;
335
+ }
336
+ labels.set(part.partId, `part-${index + 1}`);
337
+ });
338
+ return labels;
339
+ }
340
+ function shortHash(value) {
341
+ return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 8);
342
+ }
343
+ /**
344
+ * Per FIELD, the first part that KNOWS wins. "First part in sort order" alone
345
+ * would enshrine UNKNOWN over a value a later part detected — recording
346
+ * ignorance while holding the evidence — and would then "warn" about drift
347
+ * between UNKNOWN and the truth, which is noise. Real drift between two known
348
+ * values is stated: parts of one logical run disagreeing on branch or SDK
349
+ * version is exactly the drifted-runners case, and picking a winner silently
350
+ * is the kind of claim this product exists not to make.
351
+ */
352
+ function mergeSource(parts) {
353
+ const warnings = [];
354
+ const pick = (field) => {
355
+ const known = parts
356
+ .map((part) => part.source[field])
357
+ .filter((value) => value !== undefined && value !== UNKNOWN);
358
+ const distinct = [...new Set(known)];
359
+ if (distinct.length > 1) {
360
+ warnings.push(`Invocations in this run group disagree on ${field} (${distinct.join(' vs ')}); ` +
361
+ `the merged run records "${distinct[0]}". This usually means the group id was ` +
362
+ `shared across executions that are not one logical run.`);
363
+ }
364
+ return distinct[0] ?? UNKNOWN;
365
+ };
366
+ const ci = parts.map((part) => part.source.ci).find((value) => value !== undefined);
367
+ return {
368
+ branch: pick('branch'),
369
+ commit: pick('commit'),
370
+ runSource: pick('runSource') === 'ci' ? 'ci' : 'local',
371
+ sdkVersion: pick('sdkVersion'),
372
+ axeVersion: pick('axeVersion'),
373
+ ...(ci ? { ci } : {}),
374
+ warnings,
375
+ };
376
+ }
377
+ /**
378
+ * Combine the parts' per-invocation coverage facts.
379
+ *
380
+ * The two axes combine in OPPOSITE directions, and getting either backwards would
381
+ * produce a confident wrong answer:
382
+ *
383
+ * - `declaredAxes` is a UNION. Every value any lane configured is a value this
384
+ * run could have produced evidence for, so the denominator is the whole set.
385
+ * - `unmeasuredAxes` is an INTERSECTION. An axis one lane sets IS measured
386
+ * somewhere in the run, so only an axis no lane set anywhere is unmeasured.
387
+ * A union here would report `forcedColors` as unmeasured on a run that
388
+ * deliberately sharded it into its own lane, which is precisely wrong.
389
+ */
390
+ export function mergedCoverage(parts) {
391
+ const axes = {
392
+ theme: new Set(),
393
+ persona: new Set(),
394
+ state: new Set(),
395
+ };
396
+ const projects = new Set();
397
+ let unmeasured;
398
+ let any = false;
399
+ for (const part of parts) {
400
+ const coverage = part.coverage;
401
+ if (!coverage)
402
+ continue;
403
+ any = true;
404
+ for (const [axis, values] of Object.entries(coverage.declaredAxes ?? {})) {
405
+ const target = axes[axis];
406
+ if (!target)
407
+ continue;
408
+ for (const value of values ?? [])
409
+ target.add(value);
410
+ }
411
+ for (const value of coverage.declaredProjects ?? [])
412
+ projects.add(value);
413
+ const partUnmeasured = new Set(coverage.unmeasuredAxes);
414
+ if (unmeasured === undefined)
415
+ unmeasured = partUnmeasured;
416
+ else
417
+ unmeasured = new Set([...unmeasured].filter((axis) => partUnmeasured.has(axis)));
418
+ }
419
+ if (!any)
420
+ return {};
421
+ const declaredAxes = {};
422
+ for (const [axis, values] of Object.entries(axes)) {
423
+ if (values.size > 0)
424
+ declaredAxes[axis] = [...values].sort();
425
+ }
426
+ return {
427
+ coverage: {
428
+ ...(Object.keys(declaredAxes).length > 0
429
+ ? { declaredAxes: declaredAxes }
430
+ : {}),
431
+ ...(projects.size > 0 ? { declaredProjects: [...projects].sort() } : {}),
432
+ unmeasuredAxes: [...(unmeasured ?? [])].sort(),
433
+ },
434
+ };
435
+ }
436
+ /**
437
+ * The config path, only when every part agrees on it.
438
+ *
439
+ * A merged group spanning several configs has no single config, and naming one of
440
+ * them would make every derived repro command wrong for the other lanes. Omitted
441
+ * is the honest answer there.
442
+ */
443
+ /**
444
+ * The parallelism a merged group ran at: the SUM across lanes.
445
+ *
446
+ * Summed rather than maxed because the lanes genuinely ran their own workers, and
447
+ * a merged run's imbalance question is about the whole fleet. Absent when no part
448
+ * reported it, so the imbalance framing stays withheld rather than assumed.
449
+ */
450
+ export function mergedConfiguredWorkers(parts) {
451
+ const known = parts
452
+ .map((part) => part.configuredWorkers)
453
+ .filter((value) => typeof value === 'number' && value > 0);
454
+ if (known.length === 0)
455
+ return undefined;
456
+ return known.reduce((total, value) => total + value, 0);
457
+ }
458
+ function mergedConfigFile(parts) {
459
+ const paths = new Set(parts.map((part) => part.configFile).filter(Boolean));
460
+ if (paths.size !== 1)
461
+ return {};
462
+ const [only] = [...paths];
463
+ return only ? { configFile: only } : {};
464
+ }
465
+ function coverageOf(group, completed, missing, stale, interrupted) {
466
+ if (missing.length > 0 || stale.length > 0 || interrupted)
467
+ return 'partial';
468
+ const expected = group.expectedParts;
469
+ if (expected === undefined)
470
+ return 'unknown';
471
+ return completed === expected ? 'complete' : 'partial';
472
+ }
473
+ async function writeMerged(inputs, state) {
474
+ const { group, groupDir } = inputs;
475
+ const groupWarnings = [...group.warnings];
476
+ /*
477
+ * Freshness: parts hours older than the newest are another execution's
478
+ * leftovers under a reused id. Excluded and NAMED — silently merging them
479
+ * would let stale passing evidence mask a live regression.
480
+ */
481
+ const newest = state.parts.reduce((max, part) => (part.generatedAt > max ? part.generatedAt : max), '');
482
+ const fresh = [];
483
+ const stale = [];
484
+ for (const part of state.parts) {
485
+ const age = Date.parse(newest) - Date.parse(part.generatedAt);
486
+ if (Number.isFinite(age) && age > STALE_PART_MS) {
487
+ stale.push(part.partId);
488
+ }
489
+ else {
490
+ fresh.push(part);
491
+ }
492
+ }
493
+ if (stale.length > 0) {
494
+ groupWarnings.push(`${stale.length} invocation${stale.length === 1 ? '' : 's'} in this run group ` +
495
+ `(${stale.join(', ')}) finished hours before the newest one and ${stale.length === 1 ? 'was' : 'were'} ` +
496
+ 'excluded from the merge. This usually means SETSQUARE_RUN_ID was reused across suite ' +
497
+ 'executions; mint a fresh id per execution.');
498
+ }
499
+ const labels = partLabels(fresh);
500
+ // The union the whole feature exists to produce: every part's outcomes, with
501
+ // identity namespaced per invocation so nothing can collapse across lanes.
502
+ const outcomes = [];
503
+ const partWarnings = [];
504
+ for (const part of fresh) {
505
+ const label = labels.get(part.partId) ?? part.partId;
506
+ const visitPrefix = shortHash(part.partId);
507
+ for (const outcome of part.outcomes) {
508
+ outcomes.push({
509
+ ...outcome,
510
+ testId: `${label} :: ${outcome.testId}`,
511
+ lane: label,
512
+ inference: part.inference,
513
+ records: outcome.records.map((record) => record.kind === 'scan'
514
+ ? { ...record, visitRef: `${visitPrefix}:${record.visitRef}` }
515
+ : record),
516
+ });
517
+ }
518
+ // Provenance kept, duplication kept: the same warning firing in two lanes
519
+ // is two facts, not one repeated.
520
+ for (const warning of part.warnings)
521
+ partWarnings.push(`[${label}] ${warning}`);
522
+ }
523
+ const source = mergeSource(fresh);
524
+ groupWarnings.push(...source.warnings);
525
+ const generatedAt = newest || new Date().toISOString();
526
+ /*
527
+ * The accounting the artifact stands or falls on: who began, who was merged,
528
+ * and the difference between those numbers — which is evidence that is
529
+ * ABSENT, and must read that way. Computed BEFORE the envelope builds,
530
+ * because the coverage verdict now travels on the wire in `source.runGroup`.
531
+ */
532
+ const completedIds = new Set(fresh.map((part) => part.partId));
533
+ const missing = state.begun.filter((id) => !completedIds.has(id) && !stale.includes(id));
534
+ const interruptedParts = fresh.filter((part) => part.playwrightStatus === 'interrupted' || part.playwrightStatus === 'timedout');
535
+ const coverage = coverageOf(group, fresh.length, missing, stale, interruptedParts.length > 0);
536
+ /*
537
+ * Unknowns are RECOMPUTED for the merged source, never unioned from parts: a
538
+ * branch one lane could not detect but another did is KNOWN for the merged
539
+ * run, and carrying the lane's ignorance forward would record less than the
540
+ * artifact holds.
541
+ */
542
+ const { envelope, warnings: envelopeWarnings, unknowns, } = buildEnvelope({
543
+ outcomes,
544
+ sdkVersion: source.sdkVersion,
545
+ axeVersion: source.axeVersion,
546
+ branch: source.branch,
547
+ commit: source.commit,
548
+ runSource: source.runSource,
549
+ ...(source.ci ? { ci: source.ci } : {}),
550
+ runGroup: {
551
+ id: group.id,
552
+ coverage,
553
+ completed: fresh.length,
554
+ expectedParts: group.expectedParts ?? null,
555
+ },
556
+ generatedAt,
557
+ });
558
+ if (missing.length > 0) {
559
+ groupWarnings.push(`${missing.length} invocation${missing.length === 1 ? '' : 's'} in this run group ` +
560
+ `(${missing.join(', ')}) started but ${missing.length === 1 ? 'its' : 'their'} results were ` +
561
+ 'never merged — crashed, cut short, or still running. That evidence is absent from this ' +
562
+ 'artifact, not clean. Treat this merged run as partial, never as a baseline.');
563
+ }
564
+ if (interruptedParts.length > 0) {
565
+ groupWarnings.push(`Playwright reported ${interruptedParts.length === 1 ? 'an invocation' : 'invocations'} in this ` +
566
+ `run group as cut short (${interruptedParts
567
+ .map((part) => `${labels.get(part.partId)}: ${part.playwrightStatus}`)
568
+ .join(', ')}). ${interruptedParts.length === 1 ? 'Its' : 'Their'} evidence is truncated, so ` +
569
+ 'this merged run is partial.');
570
+ }
571
+ if (group.expectedParts !== undefined && fresh.length > group.expectedParts) {
572
+ groupWarnings.push(`SETSQUARE_RUN_PARTS declared ${group.expectedParts} invocation${group.expectedParts === 1 ? '' : 's'} ` +
573
+ `but ${fresh.length} were merged. The census is wrong, so this artifact cannot claim completeness.`);
574
+ }
575
+ if (coverage === 'unknown') {
576
+ groupWarnings.push(`Merged ${fresh.length} invocation${fresh.length === 1 ? '' : 's'} of run group "${group.id}". ` +
577
+ 'Whether more were intended is not knowable from this artifact — set SETSQUARE_RUN_PARTS to ' +
578
+ 'declare the count, and treat the artifact as current rather than final until then.');
579
+ }
580
+ /*
581
+ * plannedTests survives the merge only when EVERY part could say it. A sum
582
+ * over parts where one is unknown clamps the not-run arithmetic toward zero
583
+ * — a partial census reading as MORE complete — so the honest value there is
584
+ * "we do not know", with the part that could not say named.
585
+ */
586
+ const planned = fresh.map((part) => part.plannedTests);
587
+ const plannedTests = planned.every((value) => value !== undefined)
588
+ ? planned.reduce((sum, value) => sum + value, 0)
589
+ : undefined;
590
+ if (plannedTests === undefined && fresh.length > 0 && planned.some((v) => v !== undefined)) {
591
+ const silent = fresh.filter((part) => part.plannedTests === undefined);
592
+ groupWarnings.push(`${silent.map((part) => labels.get(part.partId)).join(', ')} could not report how many tests ` +
593
+ 'it intended to run, so the merged run cannot say how many never ran.');
594
+ }
595
+ const startedAts = fresh.map((part) => Date.parse(part.startedAt)).filter(Number.isFinite);
596
+ const endedAts = fresh.map((part) => Date.parse(part.generatedAt)).filter(Number.isFinite);
597
+ const spanMs = startedAts.length > 0 && endedAts.length > 0
598
+ ? Math.max(0, Math.max(...endedAts) - Math.min(...startedAts))
599
+ : 0;
600
+ const pages = outcomes.reduce((total, outcome) => total + outcome.records.length, 0);
601
+ const envelopeJson = `${JSON.stringify(envelope, null, 2)}\n`;
602
+ const runJsonSha256 = createHash('sha256').update(envelopeJson, 'utf8').digest('hex');
603
+ const partsMeta = fresh.map((part) => ({
604
+ partId: part.partId,
605
+ ...(part.lane ? { lane: part.lane } : {}),
606
+ label: labels.get(part.partId) ?? part.partId,
607
+ startedAt: part.startedAt,
608
+ generatedAt: part.generatedAt,
609
+ durationMs: part.durationMs,
610
+ playwrightStatus: part.playwrightStatus,
611
+ tests: part.outcomes.length,
612
+ scans: part.outcomes.reduce((total, outcome) => total + outcome.records.filter((r) => r.kind === 'scan').length, 0),
613
+ ...(part.plannedTests !== undefined ? { plannedTests: part.plannedTests } : {}),
614
+ ...(part.reportPath ? { reportPath: part.reportPath } : {}),
615
+ }));
616
+ const groupMeta = {
617
+ id: group.id,
618
+ expectedParts: group.expectedParts ?? null,
619
+ begun: state.begun.length,
620
+ completed: fresh.length,
621
+ missing: [...missing, ...state.corrupt.filter((id) => !missing.includes(id))],
622
+ stale,
623
+ coverage,
624
+ spanMs,
625
+ runJsonSha256,
626
+ parts: partsMeta,
627
+ };
628
+ const meta = {
629
+ runId: null,
630
+ submitted: false,
631
+ sdkVersion: envelope.source.sdkVersion,
632
+ generatedAt,
633
+ // The span, not a sum: summed durations overstate concurrent lanes and
634
+ // understate sequential ones, and either would be a fabricated number.
635
+ durationMs: spanMs,
636
+ counts: {
637
+ tests: envelope.results.length,
638
+ pages,
639
+ scans: envelope.scans.length,
640
+ ...(plannedTests !== undefined ? { plannedTests } : {}),
641
+ },
642
+ /*
643
+ * Re-derived from the MERGED outcomes, not carried in the parts.
644
+ *
645
+ * Outcomes ride through a part file whole, so every fact these reduce over is
646
+ * already here — and re-deriving means the merged numbers are computed by
647
+ * exactly the same code as a solo run's, which is the only way the two cannot
648
+ * drift apart.
649
+ */
650
+ ...localFacts(outcomes, spanMs, mergedConfiguredWorkers(fresh)),
651
+ ...mergedCoverage(fresh),
652
+ ...mergedConfigFile(fresh),
653
+ unknowns,
654
+ warnings: [...partWarnings, ...envelopeWarnings, ...groupWarnings],
655
+ group: groupMeta,
656
+ };
657
+ const outputRoot = join(groupDir, '..', '..');
658
+ let htmlReportPath;
659
+ let html;
660
+ const provisionalMeta = {
661
+ ...meta,
662
+ outputDir: outputRoot,
663
+ envelopePath: join(groupDir, 'run.json'),
664
+ };
665
+ if (inputs.renderReport) {
666
+ try {
667
+ html = inputs.renderReport(envelope, provisionalMeta, outcomes);
668
+ htmlReportPath = join(groupDir, 'report.html');
669
+ }
670
+ catch (error) {
671
+ meta.warnings.push(`The HTML report could not be rendered: ${error instanceof Error ? error.message : String(error)}. The run's evidence in run.json is unaffected.`);
672
+ }
673
+ }
674
+ let screensPath;
675
+ let screensHtml;
676
+ if (inputs.renderScreens) {
677
+ try {
678
+ screensHtml = inputs.renderScreens(outcomes, groupDir, envelope, provisionalMeta);
679
+ if (screensHtml !== undefined)
680
+ screensPath = join(groupDir, 'screens.html');
681
+ }
682
+ catch (error) {
683
+ meta.warnings.push(`The screens report could not be rendered: ${error instanceof Error ? error.message : String(error)}. The run's evidence in run.json is unaffected.`);
684
+ }
685
+ }
686
+ // `writeRunInto` lands run.json before the sidecar that describes it (down
687
+ // to its hash), so no reader can hold a meta.json whose envelope does not
688
+ // exist or does not match. The report goes last, and best-effort.
689
+ const written = await writeRunInto(groupDir, outputRoot, envelope, meta);
690
+ if (html !== undefined)
691
+ await writeFileAtomic(join(groupDir, 'report.html'), html);
692
+ if (screensHtml !== undefined)
693
+ await writeFileAtomic(join(groupDir, 'screens.html'), screensHtml);
694
+ await pruneRuns(join(groupDir, '..'), {
695
+ currentName: runDirName(generatedAt),
696
+ currentGroupName: group.dirName,
697
+ });
698
+ return {
699
+ runDir: written.runDir,
700
+ envelopePath: written.envelopePath,
701
+ metaPath: written.metaPath,
702
+ ...(htmlReportPath ? { htmlReportPath } : {}),
703
+ ...(screensPath ? { screensPath } : {}),
704
+ envelope,
705
+ meta: { ...meta, outputDir: outputRoot, envelopePath: written.envelopePath },
706
+ group: groupMeta,
707
+ };
708
+ }
709
+ //# sourceMappingURL=group.js.map