@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.
- package/LICENSE +202 -0
- package/NOTICE +57 -0
- package/README.md +338 -0
- package/THIRD-PARTY-NOTICES.md +61 -0
- package/dist/autoscan.d.ts +25 -0
- package/dist/autoscan.d.ts.map +1 -0
- package/dist/autoscan.js +332 -0
- package/dist/autoscan.js.map +1 -0
- package/dist/axe.d.ts +96 -0
- package/dist/axe.d.ts.map +1 -0
- package/dist/axe.js +210 -0
- package/dist/axe.js.map +1 -0
- package/dist/bin/setsquare.d.ts +3 -0
- package/dist/bin/setsquare.d.ts.map +1 -0
- package/dist/bin/setsquare.js +21 -0
- package/dist/bin/setsquare.js.map +1 -0
- package/dist/brand.d.ts +84 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +335 -0
- package/dist/brand.js.map +1 -0
- package/dist/checks/dynamic.d.ts +57 -0
- package/dist/checks/dynamic.d.ts.map +1 -0
- package/dist/checks/dynamic.js +184 -0
- package/dist/checks/dynamic.js.map +1 -0
- package/dist/checks/rule-pack.d.ts +34 -0
- package/dist/checks/rule-pack.d.ts.map +1 -0
- package/dist/checks/rule-pack.js +270 -0
- package/dist/checks/rule-pack.js.map +1 -0
- package/dist/checks/versions.d.ts +28 -0
- package/dist/checks/versions.d.ts.map +1 -0
- package/dist/checks/versions.js +45 -0
- package/dist/checks/versions.js.map +1 -0
- package/dist/ci.d.ts +37 -0
- package/dist/ci.d.ts.map +1 -0
- package/dist/ci.js +199 -0
- package/dist/ci.js.map +1 -0
- package/dist/contract.d.ts +334 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +35 -0
- package/dist/contract.js.map +1 -0
- package/dist/dedupe.d.ts +79 -0
- package/dist/dedupe.d.ts.map +1 -0
- package/dist/dedupe.js +102 -0
- package/dist/dedupe.js.map +1 -0
- package/dist/document.d.ts +29 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +94 -0
- package/dist/document.js.map +1 -0
- package/dist/envelope.d.ts +124 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +337 -0
- package/dist/envelope.js.map +1 -0
- package/dist/escape.d.ts +14 -0
- package/dist/escape.d.ts.map +1 -0
- package/dist/escape.js +20 -0
- package/dist/escape.js.map +1 -0
- package/dist/findings.d.ts +47 -0
- package/dist/findings.d.ts.map +1 -0
- package/dist/findings.js +229 -0
- package/dist/findings.js.map +1 -0
- package/dist/fixture.d.ts +7 -0
- package/dist/fixture.d.ts.map +1 -0
- package/dist/fixture.js +237 -0
- package/dist/fixture.js.map +1 -0
- package/dist/group.d.ts +214 -0
- package/dist/group.d.ts.map +1 -0
- package/dist/group.js +709 -0
- package/dist/group.js.map +1 -0
- package/dist/html-report.d.ts +68 -0
- package/dist/html-report.d.ts.map +1 -0
- package/dist/html-report.js +649 -0
- package/dist/html-report.js.map +1 -0
- package/dist/human.d.ts +65 -0
- package/dist/human.d.ts.map +1 -0
- package/dist/human.js +111 -0
- package/dist/human.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/infer.d.ts +65 -0
- package/dist/infer.d.ts.map +1 -0
- package/dist/infer.js +67 -0
- package/dist/infer.js.map +1 -0
- package/dist/insights.d.ts +140 -0
- package/dist/insights.d.ts.map +1 -0
- package/dist/insights.js +483 -0
- package/dist/insights.js.map +1 -0
- package/dist/install.d.ts +8 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +114 -0
- package/dist/install.js.map +1 -0
- package/dist/links.d.ts +45 -0
- package/dist/links.d.ts.map +1 -0
- package/dist/links.js +45 -0
- package/dist/links.js.map +1 -0
- package/dist/mask.d.ts +85 -0
- package/dist/mask.d.ts.map +1 -0
- package/dist/mask.js +98 -0
- package/dist/mask.js.map +1 -0
- package/dist/observe.d.ts +51 -0
- package/dist/observe.d.ts.map +1 -0
- package/dist/observe.js +233 -0
- package/dist/observe.js.map +1 -0
- package/dist/options.d.ts +249 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +185 -0
- package/dist/options.js.map +1 -0
- package/dist/output.d.ts +113 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +231 -0
- package/dist/output.js.map +1 -0
- package/dist/perf.d.ts +11 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +150 -0
- package/dist/perf.js.map +1 -0
- package/dist/reading-level.d.ts +73 -0
- package/dist/reading-level.d.ts.map +1 -0
- package/dist/reading-level.js +189 -0
- package/dist/reading-level.js.map +1 -0
- package/dist/reporter.d.ts +94 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +709 -0
- package/dist/reporter.js.map +1 -0
- package/dist/rules.d.ts +55 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +77 -0
- package/dist/rules.js.map +1 -0
- package/dist/runtime.d.ts +32 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +67 -0
- package/dist/runtime.js.map +1 -0
- package/dist/sanitise.d.ts +116 -0
- package/dist/sanitise.d.ts.map +1 -0
- package/dist/sanitise.js +247 -0
- package/dist/sanitise.js.map +1 -0
- package/dist/scan.d.ts +60 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +246 -0
- package/dist/scan.js.map +1 -0
- package/dist/screens.d.ts +98 -0
- package/dist/screens.d.ts.map +1 -0
- package/dist/screens.js +301 -0
- package/dist/screens.js.map +1 -0
- package/dist/settle.d.ts +65 -0
- package/dist/settle.d.ts.map +1 -0
- package/dist/settle.js +162 -0
- package/dist/settle.js.map +1 -0
- package/dist/shots.d.ts +109 -0
- package/dist/shots.d.ts.map +1 -0
- package/dist/shots.js +113 -0
- package/dist/shots.js.map +1 -0
- package/dist/style.d.ts +47 -0
- package/dist/style.d.ts.map +1 -0
- package/dist/style.js +154 -0
- package/dist/style.js.map +1 -0
- package/dist/suite.d.ts +218 -0
- package/dist/suite.d.ts.map +1 -0
- package/dist/suite.js +219 -0
- package/dist/suite.js.map +1 -0
- package/dist/summary.d.ts +268 -0
- package/dist/summary.d.ts.map +1 -0
- package/dist/summary.js +1308 -0
- package/dist/summary.js.map +1 -0
- package/dist/unscannable.d.ts +75 -0
- package/dist/unscannable.d.ts.map +1 -0
- package/dist/unscannable.js +111 -0
- package/dist/unscannable.js.map +1 -0
- package/dist/versions.d.ts +13 -0
- package/dist/versions.d.ts.map +1 -0
- package/dist/versions.js +39 -0
- package/dist/versions.js.map +1 -0
- package/dist/wcag-map.d.ts +19 -0
- package/dist/wcag-map.d.ts.map +1 -0
- package/dist/wcag-map.js +52 -0
- package/dist/wcag-map.js.map +1 -0
- package/dist/with-setsquare.d.ts +26 -0
- package/dist/with-setsquare.d.ts.map +1 -0
- package/dist/with-setsquare.js +70 -0
- package/dist/with-setsquare.js.map +1 -0
- 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
|