@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
@@ -0,0 +1,709 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, dirname, join, relative, resolve } from 'node:path';
3
+ import { SCAN_RECORD_ATTACHMENT, } from './contract.js';
4
+ import { infrastructureProjects } from './dedupe.js';
5
+ import { detectOrigin } from './ci.js';
6
+ import { buildEnvelope } from './envelope.js';
7
+ import { mergeGroup, mintPartId, prepareOutcomesForPart, readGroupContext, serialisableInference, writeBegunMarker, writePart, PART_VERSION, } from './group.js';
8
+ import { resolveOptions, summaryMarkdownPath } from './options.js';
9
+ import { renderHtmlReport } from './html-report.js';
10
+ import { writeHtmlReport, writeRun, writeScreensReport, writeSummaryMarkdown } from './output.js';
11
+ import { renderScreensPage, screenRows } from './screens.js';
12
+ import { collectShots, reachableShots } from './shots.js';
13
+ import { render } from './summary.js';
14
+ import { failureFacts, localFacts, slowestSteps } from './suite.js';
15
+ import { axeVersion, sdkVersion } from './versions.js';
16
+ import { criterionRuleMap } from './wcag-map.js';
17
+ /**
18
+ * The interpretation half of the SDK, and the only thing that exists once per
19
+ * run.
20
+ *
21
+ * Everything that is a policy lives here — envelope assembly, output, and (as
22
+ * later stages land) dedupe, precedence, surface normalisation and persona/theme
23
+ * resolution. The fixture observes in N workers and decides nothing; this decides
24
+ * everything and observes nothing. Splitting it any other way means two places
25
+ * implementing one policy, and they drift.
26
+ *
27
+ * Nothing in this file makes a network call, and nothing in this file can. That
28
+ * is not a configuration — there is no client, no URL, and no code path that
29
+ * would use one. "Zero network calls with no token" is asserted in the test suite
30
+ * against the real Node http stack, because a promise of restraint is not a
31
+ * property.
32
+ */
33
+ export default class SetsquareReporter {
34
+ options;
35
+ outcomes = [];
36
+ warnings = [];
37
+ startedAt = 0;
38
+ baseDir = process.cwd();
39
+ infrastructure = new Set();
40
+ reportPath;
41
+ plannedTests;
42
+ /** Set only when the orchestrator declared a run group via SETSQUARE_RUN_ID. */
43
+ group;
44
+ groupDir = '';
45
+ partId = '';
46
+ startedAtIso = '';
47
+ /** The begun-marker write, started in onBegin and settled before the merge. */
48
+ begunWrite;
49
+ /** Image attachments Playwright held in memory, which nothing can link to. */
50
+ inMemoryShots = 0;
51
+ unmeasuredAxes = [];
52
+ projectNames = [];
53
+ configuredWorkers;
54
+ configFile;
55
+ constructor(options = {}) {
56
+ this.options = resolveOptions(options);
57
+ }
58
+ /** Playwright reads this to decide whether stdout should be captured. */
59
+ printsToStdio() {
60
+ return true;
61
+ }
62
+ /**
63
+ * `playwright test --list` enumerates tests and runs none of them.
64
+ *
65
+ * Playwright still calls `onBegin` and `onEnd` for a listing, and the reporter
66
+ * used to treat that as a run: it wrote a `run.json` with zero results, printed a
67
+ * summary, and — because `pruneRuns` replaces older run directories, by the "no
68
+ * local history" design — **DELETED the previous real run's evidence.** Measured:
69
+ * a run of 14 scans, then one `--list`, and the 14-scan directory was gone,
70
+ * replaced by an empty one. `setsquare report` then pointed at an empty report.
71
+ *
72
+ * In a run group it was worse than data loss: the listing wrote a begun marker
73
+ * and never completed, so the group's census counted a part that never existed
74
+ * and the merged coverage read `partial` forever.
75
+ *
76
+ * Detected from argv rather than from "no tests ended", because a run that was
77
+ * interrupted before its first test looks identical from the Reporter API — and
78
+ * that IS a real run whose emptiness is worth recording. What the user typed is
79
+ * the only signal that separates the two.
80
+ */
81
+ listOnly = process.argv.includes('--list');
82
+ onBegin(config, suite) {
83
+ // Nothing about a listing is evidence, including a run-group begun marker.
84
+ if (this.listOnly)
85
+ return;
86
+ this.startedAt = Date.now();
87
+ /*
88
+ * How many tests this run INTENDED to execute, taken from Playwright's own
89
+ * suite tree because it is the only place the answer exists.
90
+ *
91
+ * Counting results cannot see a test that never ran, and on Eos's e2e lane a
92
+ * failing setup project took 82 of 112 tests down with it. Without this the
93
+ * summary reported ten scanned surfaces with nothing to say that it was a
94
+ * quarter of a suite — evidence from a partial run looking exactly like
95
+ * evidence from a whole one.
96
+ *
97
+ * Guarded rather than trusted: `suite` is a Playwright internal shape we only
98
+ * read, and a future version that changes it must degrade to "we do not know"
99
+ * rather than to a wrong number or a crashed reporter.
100
+ */
101
+ try {
102
+ this.plannedTests = suite?.allTests?.().length;
103
+ }
104
+ catch {
105
+ this.plannedTests = undefined;
106
+ }
107
+ // `configFile`, NOT `rootDir`. Playwright derives `rootDir` from `testDir`,
108
+ // so a config with `testDir: './specs'` reports `rootDir` as the specs
109
+ // directory — and output landed in `fixtures/specs/.setsquare`, buried inside the
110
+ // customer's test tree. The config file's own directory is what a customer
111
+ // means by "next to my config", and it is what `setsquare init` will have written
112
+ // a `.gitignore` line for.
113
+ this.baseDir = config.configFile
114
+ ? dirname(config.configFile)
115
+ : (config.rootDir ?? process.cwd());
116
+ /*
117
+ * Which projects exist to prepare the suite rather than to test it, read from
118
+ * the customer's OWN dependency graph rather than from a config list they
119
+ * would have to remember to write. On the reference implementation this
120
+ * identifies preflight, setup, auth and warmup with no configuration at all.
121
+ */
122
+ this.infrastructure = infrastructureProjects(config.projects);
123
+ this.reportPath = htmlReportPath(config, this.baseDir);
124
+ /*
125
+ * The run-group contract, read from the environment because the group id
126
+ * must change per suite execution while the config file does not — and
127
+ * because Playwright constructs reporters from JSON-safe config entries,
128
+ * so options could never carry it anyway. No SETSQUARE_RUN_ID, no group: the
129
+ * solo path below is byte-identical to what it always was.
130
+ */
131
+ this.unmeasuredAxes = unmeasuredAxes(config.projects);
132
+ this.projectNames = config.projects.map((project) => project.name).filter(Boolean);
133
+ // The PARALLELISM, which is what decides whether per-worker spread is an
134
+ // imbalance or just the shape of the projects.
135
+ if (typeof config.workers === 'number' && config.workers > 0) {
136
+ this.configuredWorkers = config.workers;
137
+ }
138
+ /*
139
+ * The config's own path, kept so a repro command can name the right `-c`.
140
+ * Absent for an inline config, where the honest output omits the flag rather
141
+ * than inventing a path.
142
+ */
143
+ if (config.configFile) {
144
+ // Relative to the config's own directory, which is `baseDir` and therefore
145
+ // the base every other path in `meta.json` is relative to. An absolute path
146
+ // would leak the machine's directory layout into a file people paste into
147
+ // issues, and would be wrong on anybody else's checkout.
148
+ this.configFile = relative(this.baseDir, config.configFile) || basename(config.configFile);
149
+ }
150
+ this.group = readGroupContext(process.env);
151
+ if (this.group) {
152
+ this.startedAtIso = new Date(this.startedAt).toISOString();
153
+ this.partId = mintPartId(this.startedAtIso);
154
+ this.groupDir = join(resolve(this.baseDir, this.options.outputDir), 'runs', this.group.dirName);
155
+ // Announce the invocation NOW, so a crash between here and onEnd leaves a
156
+ // begun marker with no part — which is how the merge knows evidence is
157
+ // absent rather than complete. onBegin cannot await; onEnd settles this.
158
+ const marker = {
159
+ partId: this.partId,
160
+ ...(this.group.lane ? { lane: this.group.lane } : {}),
161
+ startedAt: this.startedAtIso,
162
+ pid: process.pid,
163
+ };
164
+ this.begunWrite = writeBegunMarker(this.groupDir, marker).catch((error) => {
165
+ this.warnings.push(`The run-group begun marker could not be written: ${error instanceof Error ? error.message : String(error)}`);
166
+ });
167
+ }
168
+ }
169
+ onTestEnd(test, result) {
170
+ const project = test.parent.project()?.name;
171
+ /*
172
+ * The screenshots Playwright already wrote, recorded as paths.
173
+ *
174
+ * This is the only place the visual report gets its material, and it is a
175
+ * read: no capture happens here, and no image is written anywhere by this
176
+ * package (asserted in `boundaries.test.ts`). A test whose config takes no
177
+ * screenshots yields an empty list, which is why this costs a suite that
178
+ * does not want the feature exactly nothing.
179
+ */
180
+ const shots = collectShots(result.attachments);
181
+ this.inMemoryShots += shots.inMemory;
182
+ const failure = failureFacts(result.errors, result.steps);
183
+ const slowest = slowestSteps(result.steps);
184
+ this.outcomes.push({
185
+ testId: test.titlePath().filter(Boolean).join(' > '),
186
+ title: test.title,
187
+ ...(project ? { project } : {}),
188
+ // Playwright reports every attempt separately, and each is its own row.
189
+ // P7's flake detection reads this history; not capturing it would mean
190
+ // flake analysis could only ever begin from the day we added the field.
191
+ retry: result.retry,
192
+ status: mapStatus(result.status),
193
+ durationMs: result.duration,
194
+ records: readScanRecords(result, this.warnings),
195
+ ...(shots.shots.length > 0 ? { shots: shots.shots } : {}),
196
+ /*
197
+ * Three fields that were on this object all along and were being dropped.
198
+ *
199
+ * `workerIndex` is `-1` for a test that never got a worker; that maps to
200
+ * ABSENT, never to zero, because worker zero is real and the difference is
201
+ * the whole imbalance signal.
202
+ *
203
+ * `failure` is what makes `run.json` triageable by someone who did not watch
204
+ * the run. Free now, unrecoverable afterwards.
205
+ *
206
+ * `slowestSteps` is reduced HERE rather than carried as a tree, because the
207
+ * tree is unbounded and only the tail is useful.
208
+ */
209
+ ...(result.workerIndex >= 0 ? { workerIndex: result.workerIndex } : {}),
210
+ ...(failure ? { failure } : {}),
211
+ ...(slowest.length > 0 ? { slowestSteps: slowest } : {}),
212
+ });
213
+ }
214
+ async onEnd(result) {
215
+ if (this.listOnly)
216
+ return;
217
+ const generatedAt = new Date().toISOString();
218
+ const origin = detectOrigin(process.env, this.baseDir);
219
+ if (this.group) {
220
+ await this.endGrouped(result, generatedAt, origin);
221
+ return;
222
+ }
223
+ const { envelope, unknowns, warnings } = buildEnvelope({
224
+ outcomes: this.outcomes,
225
+ infrastructureProjects: this.infrastructure,
226
+ inference: this.options.inference,
227
+ sdkVersion: sdkVersion(),
228
+ axeVersion: axeVersion(),
229
+ /*
230
+ * Detected against the CONFIG's directory rather than the process working
231
+ * directory: the same suite run from a monorepo root and from its own
232
+ * package must report the same branch.
233
+ */
234
+ runSource: origin.runSource,
235
+ branch: origin.branch,
236
+ commit: origin.commit,
237
+ ...(origin.ci ? { ci: origin.ci } : {}),
238
+ generatedAt,
239
+ });
240
+ const pages = this.outcomes.reduce((total, outcome) => total + outcome.records.length, 0);
241
+ const declaredAxes = declaredAxesOf(this.options.inference);
242
+ const meta = {
243
+ runId: null,
244
+ submitted: false,
245
+ sdkVersion: envelope.source.sdkVersion,
246
+ generatedAt,
247
+ durationMs: Date.now() - this.startedAt,
248
+ counts: {
249
+ tests: envelope.results.length,
250
+ pages,
251
+ scans: envelope.scans.length,
252
+ ...(this.plannedTests !== undefined ? { plannedTests: this.plannedTests } : {}),
253
+ },
254
+ ...localFacts(this.outcomes, Date.now() - this.startedAt, this.configuredWorkers),
255
+ coverage: {
256
+ ...(declaredAxes ? { declaredAxes } : {}),
257
+ ...(this.projectNames.length > 0
258
+ ? { declaredProjects: [...new Set(this.projectNames)].sort() }
259
+ : {}),
260
+ unmeasuredAxes: this.unmeasuredAxes,
261
+ },
262
+ ...(this.configFile ? { configFile: this.configFile } : {}),
263
+ unknowns: [...origin.unknowns, ...unknowns],
264
+ warnings: [...this.warnings, ...warnings],
265
+ };
266
+ try {
267
+ const { runDir, envelopePath } = await writeRun(this.options.outputDir, this.baseDir, envelope, meta);
268
+ const criterionRules = criterionRuleMap();
269
+ /*
270
+ * The HTML report gets its own try, deliberately inside the outer one: a
271
+ * template bug must degrade to "no report.html" — never suppress the
272
+ * envelope that was already written, and never swallow the terminal
273
+ * summary that follows. The failure is reported through the same
274
+ * warnings channel as everything else the run could not do.
275
+ */
276
+ /*
277
+ * Built before the accessibility report, because that report embeds the
278
+ * same section. One renderer, two homes — see `screens.ts`.
279
+ */
280
+ const embeddedScreens = buildScreens(this.outcomes, runDir, envelope, this.inMemoryShots);
281
+ let htmlReportPath;
282
+ try {
283
+ htmlReportPath = await writeHtmlReport(runDir, renderHtmlReport({
284
+ envelope,
285
+ warnings: meta.warnings,
286
+ ...(criterionRules ? { criterionRules } : {}),
287
+ ...(embeddedScreens ? { screens: embeddedScreens.input } : {}),
288
+ ...(meta.suite ? { suite: meta.suite } : {}),
289
+ ...(meta.overhead ? { overhead: meta.overhead } : {}),
290
+ ...(meta.coverage?.declaredAxes ? { declaredAxes: meta.coverage.declaredAxes } : {}),
291
+ ...(meta.coverage ? { unmeasuredAxes: meta.coverage.unmeasuredAxes } : {}),
292
+ }));
293
+ }
294
+ catch (error) {
295
+ meta.warnings.push(`The HTML report could not be written: ${error instanceof Error ? error.message : String(error)}. The run's evidence in run.json is unaffected.`);
296
+ }
297
+ let screensPath;
298
+ try {
299
+ const screens = embeddedScreens;
300
+ if (screens) {
301
+ screens.notes.forEach((note) => meta.warnings.push(note));
302
+ if (screens.input.rows.length > 0) {
303
+ screensPath = await writeScreensReport(runDir, renderScreensPage(screens.input));
304
+ }
305
+ }
306
+ }
307
+ catch (error) {
308
+ meta.warnings.push(`The screens report could not be written: ${error instanceof Error ? error.message : String(error)}. The run's evidence in run.json is unaffected.`);
309
+ }
310
+ const summaryInput = {
311
+ envelope,
312
+ envelopePath,
313
+ warnings: meta.warnings,
314
+ promo: this.options.promo,
315
+ grid: this.options.grid,
316
+ ...(criterionRules ? { criterionRules } : {}),
317
+ ...(htmlReportPath ? { htmlReportPath } : {}),
318
+ ...(screensPath ? { screensPath } : {}),
319
+ ...(this.reportPath ? { reportPath: this.reportPath } : {}),
320
+ ...(this.plannedTests !== undefined ? { plannedTests: this.plannedTests } : {}),
321
+ ...(meta.suite ? { suite: meta.suite } : {}),
322
+ ...(meta.overhead ? { overhead: meta.overhead } : {}),
323
+ ...(meta.coverage?.declaredAxes ? { declaredAxes: meta.coverage.declaredAxes } : {}),
324
+ ...(meta.coverage ? { unmeasuredAxes: meta.coverage.unmeasuredAxes } : {}),
325
+ };
326
+ console.log(render(summaryInput, this.options.format, process.stdout.columns));
327
+ await this.writeSummaryFile(summaryInput);
328
+ }
329
+ catch (error) {
330
+ // The SDK never fails a customer's build — not even by failing to write its
331
+ // own output. A warning is the loudest thing we are entitled to do.
332
+ console.log(`\n Setsquare could not write its run output: ${error instanceof Error ? error.message : String(error)}`);
333
+ }
334
+ }
335
+ /**
336
+ * The grouped ending: write this invocation's part, then re-derive the
337
+ * merged logical run from every part present.
338
+ *
339
+ * The terminal keeps speaking for THIS invocation — a CI log per lane must
340
+ * describe its lane, and two complete-looking merged summaries in one
341
+ * pipeline log is how someone quotes the wrong one — with a group block
342
+ * naming the merged artifact and what is known about its coverage. The
343
+ * merged run.json / meta.json / report.html are the logical run.
344
+ */
345
+ async endGrouped(result, generatedAt, origin) {
346
+ if (!this.group)
347
+ return;
348
+ try {
349
+ // Settle the begun marker first: the part must never exist without it,
350
+ // or the census could count us as uninvited evidence.
351
+ await this.begunWrite;
352
+ const part = {
353
+ partVersion: PART_VERSION,
354
+ partId: this.partId,
355
+ ...(this.group.lane ? { lane: this.group.lane } : {}),
356
+ startedAt: this.startedAtIso,
357
+ generatedAt,
358
+ durationMs: Date.now() - this.startedAt,
359
+ playwrightStatus: mapInvocationStatus(result?.status),
360
+ ...(this.plannedTests !== undefined ? { plannedTests: this.plannedTests } : {}),
361
+ warnings: [...this.warnings],
362
+ unknowns: [...origin.unknowns],
363
+ ...(this.reportPath ? { reportPath: this.reportPath } : {}),
364
+ ...(this.configFile ? { configFile: this.configFile } : {}),
365
+ ...(this.configuredWorkers !== undefined
366
+ ? { configuredWorkers: this.configuredWorkers }
367
+ : {}),
368
+ coverage: {
369
+ ...(declaredAxesOf(this.options.inference)
370
+ ? { declaredAxes: declaredAxesOf(this.options.inference) }
371
+ : {}),
372
+ ...(this.projectNames.length > 0
373
+ ? { declaredProjects: [...new Set(this.projectNames)].sort() }
374
+ : {}),
375
+ unmeasuredAxes: this.unmeasuredAxes,
376
+ },
377
+ source: {
378
+ branch: origin.branch,
379
+ commit: origin.commit,
380
+ runSource: origin.runSource,
381
+ sdkVersion: sdkVersion(),
382
+ axeVersion: axeVersion(),
383
+ ...(origin.ci ? { ci: origin.ci } : {}),
384
+ },
385
+ inference: serialisableInference(this.options.inference),
386
+ outcomes: prepareOutcomesForPart(this.outcomes, this.infrastructure, this.options.inference),
387
+ };
388
+ await writePart(this.groupDir, part);
389
+ const criterionRules = criterionRuleMap();
390
+ /*
391
+ * The merged sheet is built from the MERGED outcomes, not this
392
+ * invocation's — a sharded suite's captures are spread across lanes, and a
393
+ * sheet showing only the lane that happened to merge last would be the
394
+ * most misleading artifact in the directory. Shot paths ride through the
395
+ * part files to make that possible; the ones that were captured on another
396
+ * machine are named as unreachable rather than shown broken.
397
+ */
398
+ const merged = await mergeGroup({
399
+ groupDir: this.groupDir,
400
+ group: this.group,
401
+ renderReport: (envelope, meta, outcomes) => {
402
+ const screens = buildScreens(outcomes, this.groupDir, envelope, this.inMemoryShots);
403
+ return renderHtmlReport({
404
+ envelope,
405
+ warnings: meta.warnings,
406
+ ...(criterionRules ? { criterionRules } : {}),
407
+ ...(meta.group ? { group: meta.group } : {}),
408
+ ...(screens ? { screens: screens.input } : {}),
409
+ // The MERGED facts here, unlike the terminal above: this report
410
+ // describes the whole group, which is what the file is for.
411
+ ...(meta.suite ? { suite: meta.suite } : {}),
412
+ ...(meta.overhead ? { overhead: meta.overhead } : {}),
413
+ ...(meta.coverage?.declaredAxes ? { declaredAxes: meta.coverage.declaredAxes } : {}),
414
+ ...(meta.coverage ? { unmeasuredAxes: meta.coverage.unmeasuredAxes } : {}),
415
+ });
416
+ },
417
+ renderScreens: (outcomes, runDir, envelope, meta) => {
418
+ const screens = buildScreens(outcomes, runDir, envelope, this.inMemoryShots);
419
+ if (!screens || screens.input.rows.length === 0)
420
+ return undefined;
421
+ for (const note of screens.notes)
422
+ meta.warnings.push(note);
423
+ return renderScreensPage(screens.input);
424
+ },
425
+ });
426
+ // This invocation's own numbers, for this invocation's own log.
427
+ const own = buildEnvelope({
428
+ outcomes: this.outcomes,
429
+ infrastructureProjects: this.infrastructure,
430
+ inference: this.options.inference,
431
+ sdkVersion: sdkVersion(),
432
+ axeVersion: axeVersion(),
433
+ runSource: origin.runSource,
434
+ branch: origin.branch,
435
+ commit: origin.commit,
436
+ ...(origin.ci ? { ci: origin.ci } : {}),
437
+ generatedAt,
438
+ });
439
+ const ownFacts = localFacts(this.outcomes, Date.now() - this.startedAt, this.configuredWorkers);
440
+ const summaryInput = {
441
+ envelope: own.envelope,
442
+ envelopePath: merged.envelopePath,
443
+ warnings: [...this.warnings, ...own.warnings],
444
+ promo: this.options.promo,
445
+ grid: this.options.grid,
446
+ ...(criterionRules ? { criterionRules } : {}),
447
+ ...(merged.htmlReportPath ? { htmlReportPath: merged.htmlReportPath } : {}),
448
+ ...(merged.screensPath ? { screensPath: merged.screensPath } : {}),
449
+ ...(this.reportPath ? { reportPath: this.reportPath } : {}),
450
+ ...(this.plannedTests !== undefined ? { plannedTests: this.plannedTests } : {}),
451
+ /*
452
+ * THIS invocation's own facts, not the merged group's.
453
+ *
454
+ * The terminal summary above the run-group block describes this
455
+ * invocation only (its own comment says so), so quoting the merged
456
+ * overhead or the merged worker spread here would attribute other
457
+ * lanes' work to this one. The merged figures live in the merged
458
+ * artifacts, which the block below points at.
459
+ */
460
+ ...(ownFacts.suite ? { suite: ownFacts.suite } : {}),
461
+ ...(ownFacts.overhead ? { overhead: ownFacts.overhead } : {}),
462
+ ...(declaredAxesOf(this.options.inference)
463
+ ? { declaredAxes: declaredAxesOf(this.options.inference) }
464
+ : {}),
465
+ unmeasuredAxes: this.unmeasuredAxes,
466
+ group: {
467
+ id: this.group.id,
468
+ ...(this.group.lane ? { lane: this.group.lane } : {}),
469
+ partId: this.partId,
470
+ mergedParts: merged.group.completed,
471
+ labels: merged.group.parts.map((p) => p.label),
472
+ expectedParts: merged.group.expectedParts,
473
+ missing: merged.group.missing.length,
474
+ coverage: merged.group.coverage,
475
+ },
476
+ };
477
+ console.log(render(summaryInput, this.options.format, process.stdout.columns));
478
+ await this.writeSummaryFile(summaryInput);
479
+ }
480
+ catch (error) {
481
+ console.log(`\n Setsquare could not write its run-group output: ${error instanceof Error ? error.message : String(error)}`);
482
+ }
483
+ }
484
+ /**
485
+ * The same summary again, as markdown, when someone asked for a copy.
486
+ *
487
+ * Rendered from the SAME `SummaryInput` the terminal was given, which is the
488
+ * whole point: a CI job summary that disagreed with the log above it would be
489
+ * worse than no job summary at all. `format` is not consulted — this
490
+ * destination is markdown by definition, and a suite configured for `json`
491
+ * still wants a readable panel.
492
+ *
493
+ * Silent on failure, and deliberately so. This is the last thing the reporter
494
+ * does; a path the process cannot write must not swallow the summary that was
495
+ * already printed or the evidence that was already saved.
496
+ */
497
+ async writeSummaryFile(input) {
498
+ const path = summaryMarkdownPath();
499
+ if (path === undefined)
500
+ return;
501
+ try {
502
+ await writeSummaryMarkdown(this.baseDir, path, render(input, 'markdown'));
503
+ }
504
+ catch {
505
+ // Intentionally silent: see above.
506
+ }
507
+ }
508
+ }
509
+ /**
510
+ * Assemble the contact sheet's input, and say what it could not show.
511
+ *
512
+ * The one place reachability is checked, because it is a policy decision rather
513
+ * than a rendering one: a merged run group can hold parts captured on other
514
+ * machines, and a suite that cleans its output directory between the run and the
515
+ * reading leaves every path dangling. Either way the honest artifact links to
516
+ * the files that are there and NAMES the ones that are not — a page of broken
517
+ * image icons is how a QA-facing report loses its reader for good, and a silently
518
+ * shortened page is worse, because it looks complete.
519
+ *
520
+ * Returns `undefined` only when the run produced no image attachments at all, so
521
+ * a suite that takes no screenshots pays nothing and sees nothing.
522
+ */
523
+ function buildScreens(outcomes, runDir, envelope, inMemory) {
524
+ if (!outcomes.some((outcome) => (outcome.shots?.length ?? 0) > 0) && inMemory === 0) {
525
+ return undefined;
526
+ }
527
+ let missing = 0;
528
+ const resolved = outcomes.map((outcome) => {
529
+ if (!outcome.shots || outcome.shots.length === 0)
530
+ return outcome;
531
+ const check = reachableShots(outcome.shots);
532
+ missing += check.missing;
533
+ return { ...outcome, shots: check.shots };
534
+ });
535
+ const rows = screenRows(resolved);
536
+ const notes = [];
537
+ if (missing > 0) {
538
+ notes.push(`${missing} screenshot${missing === 1 ? '' : 's'} referenced by this run ${missing === 1 ? 'is' : 'are'} no longer at the path Playwright recorded, so the screens report cannot show ${missing === 1 ? 'it' : 'them'}. On a merged run group this normally means another machine captured ${missing === 1 ? 'it' : 'them'}; otherwise the test output directory has been cleaned or moved.`);
539
+ }
540
+ if (inMemory > 0) {
541
+ notes.push(`${inMemory} image attachment${inMemory === 1 ? '' : 's'} in this run ${inMemory === 1 ? 'was' : 'were'} attached as a body rather than a path, so ${inMemory === 1 ? 'it exists' : 'they exist'} only in Playwright's memory and the screens report cannot link to ${inMemory === 1 ? 'it' : 'them'}. Attach with \`path\` to include ${inMemory === 1 ? 'it' : 'them'}.`);
542
+ }
543
+ const withShots = rows.length;
544
+ const testsWithoutShots = Math.max(0, outcomes.length - withShots);
545
+ return {
546
+ input: {
547
+ rows,
548
+ runDir,
549
+ testsWithoutShots,
550
+ generatedAt: envelope.generatedAt,
551
+ branch: envelope.source.branch,
552
+ commit: envelope.source.commit,
553
+ notes,
554
+ },
555
+ notes,
556
+ };
557
+ }
558
+ /** Playwright's whole-invocation verdict, defensively mapped. */
559
+ function mapInvocationStatus(status) {
560
+ switch (status) {
561
+ case 'passed':
562
+ case 'failed':
563
+ case 'timedout':
564
+ case 'interrupted':
565
+ return status;
566
+ default:
567
+ return 'unknown';
568
+ }
569
+ }
570
+ function mapStatus(status) {
571
+ switch (status) {
572
+ case 'passed':
573
+ case 'failed':
574
+ case 'timedOut':
575
+ case 'skipped':
576
+ return status;
577
+ default:
578
+ // `interrupted`, and anything Playwright adds later. Reported as failed
579
+ // rather than dropped: a test that did not finish is not a test that passed.
580
+ return 'failed';
581
+ }
582
+ }
583
+ /**
584
+ * Pull our attachments back out of the test result.
585
+ *
586
+ * Tolerates both shapes Playwright uses — an in-memory `body`, or a `path` when
587
+ * attachments have been spilled to disk — because which one you get depends on
588
+ * the customer's config rather than on anything we control.
589
+ */
590
+ function readScanRecords(result, warnings) {
591
+ const records = [];
592
+ for (const attachment of result.attachments) {
593
+ if (attachment.name !== SCAN_RECORD_ATTACHMENT)
594
+ continue;
595
+ try {
596
+ const raw = attachment.body?.toString('utf8');
597
+ if (!raw) {
598
+ // A path-only attachment needs a synchronous read, which this hook cannot
599
+ // do. Recorded rather than dropped silently — missing evidence must never
600
+ // look like clean evidence.
601
+ warnings.push('A scan record was written to disk rather than kept in memory and could not be read back.');
602
+ continue;
603
+ }
604
+ records.push(JSON.parse(raw));
605
+ }
606
+ catch (error) {
607
+ warnings.push(`A scan record could not be parsed: ${error instanceof Error ? error.message : String(error)}`);
608
+ }
609
+ }
610
+ return records;
611
+ }
612
+ /**
613
+ * Where Playwright put its own HTML report, if it made one.
614
+ *
615
+ * Read from the customer's reporter configuration rather than assumed, because
616
+ * `outputFolder` is routinely overridden — and a link to a directory that does not
617
+ * exist is worse than no link, since it sends someone looking for a file that was
618
+ * never written.
619
+ *
620
+ * Checked for existence at the end of the run for the same reason: the html
621
+ * reporter can be configured and still produce nothing, and this line is only
622
+ * worth printing when there is something behind it.
623
+ */
624
+ /**
625
+ * Playwright `use` options that would create an evidence axis and that no project set.
626
+ *
627
+ * The honest, cheap answer to the gap analysis's "`forced-colors` and
628
+ * `prefers-reduced-motion` are one context option away". For the CUSTOMER'S config
629
+ * that is true; for the SDK it is not — as evidence axes they would change the
630
+ * scan context, the DEDUPE KEY and the grid, and getting that wrong makes evidence
631
+ * silently collapse across settings, which is worse than not having the axis.
632
+ *
633
+ * So this states what was not measured and stops there. Recommending the projects
634
+ * is `setsquare doctor`'s job.
635
+ */
636
+ const AXIS_OPTIONS = ['colorScheme', 'reducedMotion', 'forcedColors'];
637
+ export function unmeasuredAxes(projects) {
638
+ const set = new Set(AXIS_OPTIONS);
639
+ for (const project of projects) {
640
+ const use = project.use;
641
+ /*
642
+ * Both places, because only ONE of them is even legal for two of these axes.
643
+ *
644
+ * `colorScheme` is a first-class `use:` option, but `forcedColors` and
645
+ * `reducedMotion` are not: Playwright's `PlaywrightTestOptions` does not
646
+ * declare them, and its own documented example sets them through
647
+ * `use: { contextOptions: { reducedMotion: 'reduce' } }`. Reading `use` alone
648
+ * meant a customer who had configured forced-colors the only way the types
649
+ * allow was still told the axis was unmeasured — a false statement about
650
+ * their own configuration, in the output whose whole job is not making those.
651
+ *
652
+ * Found while writing the recipes: the snippet demonstrating it did not compile.
653
+ */
654
+ const contextOptions = use?.['contextOptions'];
655
+ for (const option of AXIS_OPTIONS) {
656
+ // `null` is a deliberate Playwright value ("no preference"), which IS a
657
+ // setting — so only genuine absence counts as unmeasured.
658
+ const set_here = (use && option in use && use[option] !== undefined) ||
659
+ (contextOptions && option in contextOptions && contextOptions[option] !== undefined);
660
+ if (set_here)
661
+ set.delete(option);
662
+ }
663
+ }
664
+ return [...set];
665
+ }
666
+ /**
667
+ * The axis values the CUSTOMER declared, which is the only real denominator.
668
+ *
669
+ * Read from their own `themes`/`personas` maps and their project list — never from
670
+ * what happened to be scanned, because a value nobody ever visited leaves no trace
671
+ * to count. "4 personas configured, 1 with evidence" is a defensible sentence;
672
+ * anything derived from observed values alone is circular.
673
+ *
674
+ * Returns `undefined` when the config declared nothing we can read, so a renderer
675
+ * suppresses the line entirely rather than falling back to N = M and reporting
676
+ * perfect coverage of whatever ran. Callbacks are deliberately not consulted: a
677
+ * function can only be asked about a value we already have, so it can confirm an
678
+ * observation but never enumerate the possibilities.
679
+ */
680
+ function declaredAxesOf(inference) {
681
+ const declared = {};
682
+ const themes = inference?.themes
683
+ ? [...new Set(Object.values(inference.themes))].sort()
684
+ : [];
685
+ if (themes.length > 0)
686
+ declared['theme'] = themes;
687
+ const personas = inference?.personas
688
+ ? [...new Set(Object.values(inference.personas))].sort()
689
+ : [];
690
+ if (personas.length > 0)
691
+ declared['persona'] = personas;
692
+ return Object.keys(declared).length > 0 ? declared : undefined;
693
+ }
694
+ function htmlReportPath(config, baseDir) {
695
+ const entries = Array.isArray(config.reporter) ? config.reporter : [];
696
+ for (const entry of entries) {
697
+ const [name, options] = Array.isArray(entry) ? entry : [entry, undefined];
698
+ if (name !== 'html' && !String(name).endsWith('html'))
699
+ continue;
700
+ const folder = options?.outputFolder;
701
+ return resolve(baseDir, folder ?? 'playwright-report');
702
+ }
703
+ // Playwright writes `playwright-report/` by default when no reporter is
704
+ // configured at all, which is the common case for a suite that has not been
705
+ // customised.
706
+ const fallback = join(baseDir, 'playwright-report');
707
+ return existsSync(fallback) ? fallback : undefined;
708
+ }
709
+ //# sourceMappingURL=reporter.js.map