@sdods/core 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/analyze/index.d.ts +2 -0
- package/dist/analyze/index.js +2 -0
- package/dist/analyze/traceability-render.d.ts +5 -0
- package/dist/analyze/traceability-render.js +254 -0
- package/dist/analyze/traceability.d.ts +87 -0
- package/dist/analyze/traceability.js +559 -0
- package/dist/api/json-path.d.ts +11 -1
- package/dist/api/json-path.js +23 -1
- package/dist/config/emulation.d.ts +74 -0
- package/dist/config/emulation.js +154 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/runner.js +4 -1
- package/dist/config/tags.d.ts +1 -1
- package/dist/config/tags.js +7 -0
- package/dist/errors.d.ts +1 -1
- package/dist/fixtures/api-context.d.ts +19 -1
- package/dist/fixtures/api-context.js +28 -1
- package/dist/fixtures/test.js +36 -0
- package/dist/fixtures/types.d.ts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/lint/index.js +86 -0
- package/dist/load/index.d.ts +4 -0
- package/dist/load/index.js +4 -0
- package/dist/load/profile.d.ts +22 -0
- package/dist/load/profile.js +109 -0
- package/dist/load/run.d.ts +85 -0
- package/dist/load/run.js +255 -0
- package/dist/load/script.d.ts +27 -0
- package/dist/load/script.js +188 -0
- package/dist/mail/extract.d.ts +38 -0
- package/dist/mail/extract.js +106 -0
- package/dist/mail/index.d.ts +8 -0
- package/dist/mail/index.js +11 -0
- package/dist/mail/mailpit.d.ts +26 -0
- package/dist/mail/mailpit.js +124 -0
- package/dist/mail/types.d.ts +38 -0
- package/dist/mail/types.js +2 -0
- package/dist/matrix/index.d.ts +104 -0
- package/dist/matrix/index.js +486 -0
- package/dist/steps/api.steps.js +5 -7
- package/dist/steps/browser.steps.d.ts +12 -0
- package/dist/steps/browser.steps.js +154 -3
- package/dist/steps/canvas.steps.d.ts +9 -0
- package/dist/steps/canvas.steps.js +331 -0
- package/dist/steps/email.steps.d.ts +2 -0
- package/dist/steps/email.steps.js +182 -0
- package/dist/steps/hybrid.steps.js +2 -2
- package/dist/steps/index.d.ts +2 -0
- package/dist/steps/index.js +2 -0
- package/package.json +4 -3
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { extname, join, relative } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { parse as parseCsv } from 'csv-parse/sync';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
import { parseRunnerProjectName, runFiles, scenarioFiles, } from '@sdods/contracts';
|
|
7
|
+
import { SdodsError } from '../errors.js';
|
|
8
|
+
import { parseTagValues } from '../config/tags.js';
|
|
9
|
+
import { parseFeatureFile, scenariosOf } from '../lint/gherkin.js';
|
|
10
|
+
/**
|
|
11
|
+
* Requirement traceability: `@req:<id>` tags on scenarios → the scenarios' results in one run.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately read-only over files that already exist (feature files, the run directory). The
|
|
14
|
+
* requirement id is opaque — SDODS does not know or decide what it points at — and the sign-off
|
|
15
|
+
* block of the export is left empty for a person to fill in.
|
|
16
|
+
*/
|
|
17
|
+
export const REQ_TAG = 'req';
|
|
18
|
+
// ── requirements file ────────────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Read a requirements file. YAML/JSON accepts a list (`- id: REQ-1` / `title:` or plain strings),
|
|
21
|
+
* a map (`REQ-1: Title`), or either under a top-level `requirements:` key. CSV needs an `id`
|
|
22
|
+
* column and may have a `title` column.
|
|
23
|
+
*/
|
|
24
|
+
export function loadRequirements(file) {
|
|
25
|
+
if (!existsSync(file)) {
|
|
26
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `Requirements file not found: ${file}`, {
|
|
27
|
+
hint: 'Fix traceability.requirements in sdods.project.yaml (a path relative to the project root).',
|
|
28
|
+
exitCode: 2,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const text = readFileSync(file, 'utf8');
|
|
32
|
+
const ext = extname(file).toLowerCase();
|
|
33
|
+
let defs;
|
|
34
|
+
try {
|
|
35
|
+
defs = ext === '.csv' ? requirementsFromCsv(text) : requirementsFromData(parseYaml(text));
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e instanceof SdodsError)
|
|
39
|
+
throw e;
|
|
40
|
+
throw new SdodsError('CONFIG_INVALID', `Cannot read requirements file ${file}: ${e.message}`, { exitCode: 2 });
|
|
41
|
+
}
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const d of defs) {
|
|
45
|
+
if (!d.id || seen.has(d.id))
|
|
46
|
+
continue;
|
|
47
|
+
seen.add(d.id);
|
|
48
|
+
out.push(d);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function requirementsFromCsv(text) {
|
|
53
|
+
const rows = parseCsv(text, {
|
|
54
|
+
columns: (header) => header.map((h) => h.trim().toLowerCase()),
|
|
55
|
+
skip_empty_lines: true,
|
|
56
|
+
trim: true,
|
|
57
|
+
bom: true,
|
|
58
|
+
});
|
|
59
|
+
if (rows.length && !('id' in rows[0])) {
|
|
60
|
+
throw new SdodsError('CONFIG_INVALID', 'Requirements CSV has no "id" column.', {
|
|
61
|
+
hint: 'The first row is the header: id,title',
|
|
62
|
+
exitCode: 2,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return rows.map((r) => ({ id: String(r.id ?? '').trim(), title: r.title || undefined }));
|
|
66
|
+
}
|
|
67
|
+
function requirementsFromData(data) {
|
|
68
|
+
if (data && typeof data === 'object' && !Array.isArray(data) && 'requirements' in data)
|
|
69
|
+
return requirementsFromData(data.requirements);
|
|
70
|
+
if (Array.isArray(data)) {
|
|
71
|
+
return data.map((item) => {
|
|
72
|
+
if (typeof item === 'string' || typeof item === 'number')
|
|
73
|
+
return { id: String(item) };
|
|
74
|
+
const o = (item ?? {});
|
|
75
|
+
const title = o.title ?? o.name ?? o.summary;
|
|
76
|
+
return { id: String(o.id ?? '').trim(), title: title == null ? undefined : String(title) };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (data && typeof data === 'object') {
|
|
80
|
+
return Object.entries(data).map(([id, v]) => {
|
|
81
|
+
const title = v && typeof v === 'object' ? v.title : (v ?? undefined);
|
|
82
|
+
return { id, title: title == null ? undefined : String(title) };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (data == null)
|
|
86
|
+
return [];
|
|
87
|
+
throw new SdodsError('CONFIG_INVALID', 'Requirements file must be a list or a map of requirement ids.', { exitCode: 2 });
|
|
88
|
+
}
|
|
89
|
+
/** The requirements declared by the project, or null when it configures no requirements file. */
|
|
90
|
+
export function projectRequirements(project) {
|
|
91
|
+
const rel = project.traceability?.requirements;
|
|
92
|
+
if (!rel)
|
|
93
|
+
return null;
|
|
94
|
+
return loadRequirements(join(project.root, rel));
|
|
95
|
+
}
|
|
96
|
+
export function requirementUrl(project, id) {
|
|
97
|
+
const tpl = project.traceability?.link;
|
|
98
|
+
return tpl ? tpl.replaceAll('{id}', encodeURIComponent(id)) : undefined;
|
|
99
|
+
}
|
|
100
|
+
function listFeatures(root) {
|
|
101
|
+
const out = [];
|
|
102
|
+
const walk = (dir) => {
|
|
103
|
+
if (!existsSync(dir))
|
|
104
|
+
return;
|
|
105
|
+
for (const name of readdirSync(dir).sort()) {
|
|
106
|
+
const p = join(dir, name);
|
|
107
|
+
if (statSync(p).isDirectory()) {
|
|
108
|
+
if (name !== '__screenshots__')
|
|
109
|
+
walk(p);
|
|
110
|
+
}
|
|
111
|
+
else if (name.endsWith('.feature'))
|
|
112
|
+
out.push(p);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
walk(join(root, 'features'));
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Every scenario of the project with its effective tags (Feature and Rule tags inherited). */
|
|
119
|
+
export function traceScenarios(project, files = listFeatures(project.root)) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
const parsed = parseFeatureFile(file);
|
|
123
|
+
if (parsed.errors.length)
|
|
124
|
+
continue;
|
|
125
|
+
const feature = relative(project.root, file).replace(/\\/g, '/');
|
|
126
|
+
const featureName = parsed.document.feature?.name ?? '';
|
|
127
|
+
for (const sc of scenariosOf(parsed)) {
|
|
128
|
+
out.push({
|
|
129
|
+
feature,
|
|
130
|
+
featureName,
|
|
131
|
+
line: sc.line,
|
|
132
|
+
name: sc.name,
|
|
133
|
+
tags: sc.tags,
|
|
134
|
+
requirements: [...new Set(parseTagValues(sc.tags, REQ_TAG))],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out.sort((a, b) => a.feature.localeCompare(b.feature) || a.line - b.line);
|
|
139
|
+
}
|
|
140
|
+
const tsToIso = (ts) => ts?.seconds == null
|
|
141
|
+
? undefined
|
|
142
|
+
: new Date(Number(ts.seconds) * 1000 + Math.floor((ts.nanos ?? 0) / 1e6)).toISOString();
|
|
143
|
+
function stepStatus(s) {
|
|
144
|
+
switch ((s ?? '').toUpperCase()) {
|
|
145
|
+
case 'PASSED':
|
|
146
|
+
return 'passed';
|
|
147
|
+
case 'FAILED':
|
|
148
|
+
return 'failed';
|
|
149
|
+
case 'SKIPPED':
|
|
150
|
+
case 'PENDING':
|
|
151
|
+
case 'UNDEFINED':
|
|
152
|
+
case 'AMBIGUOUS':
|
|
153
|
+
return 'skipped';
|
|
154
|
+
default:
|
|
155
|
+
return 'unknown';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Read the scenario results of a run directory without a database.
|
|
160
|
+
*
|
|
161
|
+
* Cucumber messages (`messages.ndjson`, or its shard files) are the authoritative record: they hold
|
|
162
|
+
* every attempt in every runner project. The per-scenario `meta.json` files are the fallback — their
|
|
163
|
+
* directory is keyed without the browser, so a multi-browser run keeps only one of them per scenario.
|
|
164
|
+
*/
|
|
165
|
+
export async function readRunResults(runDir) {
|
|
166
|
+
const messageFiles = existsSync(runDir)
|
|
167
|
+
? readdirSync(runDir)
|
|
168
|
+
.filter((f) => /^messages(\.shard-\d+)?\.ndjson$/.test(f))
|
|
169
|
+
.sort()
|
|
170
|
+
: [];
|
|
171
|
+
if (messageFiles.length) {
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
const attempts = [];
|
|
174
|
+
for (const f of messageFiles) {
|
|
175
|
+
for (const a of await readMessagesFile(join(runDir, f))) {
|
|
176
|
+
const key = `${a.runnerProject}|${a.uri}|${a.line}|${a.exampleIndex}|${a.attempt}`;
|
|
177
|
+
if (seen.has(key))
|
|
178
|
+
continue;
|
|
179
|
+
seen.add(key);
|
|
180
|
+
attempts.push(a);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { source: 'cucumber-messages', attempts };
|
|
184
|
+
}
|
|
185
|
+
const metas = readScenarioMetas(runDir);
|
|
186
|
+
return metas.length
|
|
187
|
+
? { source: 'scenario-meta', attempts: metas }
|
|
188
|
+
: { source: 'none', attempts: [] };
|
|
189
|
+
}
|
|
190
|
+
async function readMessagesFile(file) {
|
|
191
|
+
const scenarioLines = new Map();
|
|
192
|
+
const exampleRows = new Map();
|
|
193
|
+
const pickles = new Map();
|
|
194
|
+
/** test case id → pickle id, and the ids of its steps that are Gherkin steps (not hooks) */
|
|
195
|
+
const testCases = new Map();
|
|
196
|
+
const started = new Map();
|
|
197
|
+
const out = [];
|
|
198
|
+
const rl = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
|
|
199
|
+
for await (const line of rl) {
|
|
200
|
+
// Attachments carry base64 bodies (traces, screenshots): by far the largest lines, never needed.
|
|
201
|
+
if (!line || line.startsWith('{"attachment"') || line.startsWith('{"testStepStarted"'))
|
|
202
|
+
continue;
|
|
203
|
+
let env;
|
|
204
|
+
try {
|
|
205
|
+
env = JSON.parse(line);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (env.gherkinDocument) {
|
|
211
|
+
const walk = (children) => {
|
|
212
|
+
for (const child of children ?? []) {
|
|
213
|
+
if (child.rule)
|
|
214
|
+
walk(child.rule.children);
|
|
215
|
+
const sc = child.scenario;
|
|
216
|
+
if (!sc)
|
|
217
|
+
continue;
|
|
218
|
+
scenarioLines.set(sc.id, { line: sc.location?.line ?? 0, name: sc.name ?? '' });
|
|
219
|
+
let row = 0;
|
|
220
|
+
for (const ex of sc.examples ?? [])
|
|
221
|
+
for (const r of ex.tableBody ?? [])
|
|
222
|
+
exampleRows.set(r.id, ++row);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
walk(env.gherkinDocument.feature?.children);
|
|
226
|
+
}
|
|
227
|
+
else if (env.pickle) {
|
|
228
|
+
const p = env.pickle;
|
|
229
|
+
pickles.set(p.id, { uri: p.uri ?? '', name: p.name ?? '', astNodeIds: p.astNodeIds ?? [] });
|
|
230
|
+
}
|
|
231
|
+
else if (env.testCase) {
|
|
232
|
+
const tc = env.testCase;
|
|
233
|
+
testCases.set(tc.id, {
|
|
234
|
+
pickleId: tc.pickleId,
|
|
235
|
+
gherkinSteps: new Set((tc.testSteps ?? []).filter((x) => x.pickleStepId).map((x) => x.id)),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
else if (env.testCaseStarted) {
|
|
239
|
+
const t = env.testCaseStarted;
|
|
240
|
+
started.set(t.id, {
|
|
241
|
+
testCaseId: t.testCaseId,
|
|
242
|
+
attempt: Number(t.attempt ?? 0),
|
|
243
|
+
startedAt: tsToIso(t.timestamp),
|
|
244
|
+
statuses: [],
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
else if (env.testStepFinished) {
|
|
248
|
+
const t = env.testStepFinished;
|
|
249
|
+
const s = started.get(t.testCaseStartedId);
|
|
250
|
+
if (!s)
|
|
251
|
+
continue;
|
|
252
|
+
const status = stepStatus(t.testStepResult?.status);
|
|
253
|
+
// A failing hook fails the scenario, but a passing one proves nothing: after-hooks run and
|
|
254
|
+
// pass on a scenario whose steps were all skipped (`@skip:<browser>`), which must stay skipped.
|
|
255
|
+
const gherkinStep = testCases.get(s.testCaseId)?.gherkinSteps.has(t.testStepId) ?? true;
|
|
256
|
+
if (status === 'failed' || gherkinStep)
|
|
257
|
+
s.statuses.push(status);
|
|
258
|
+
if (status === 'failed' && !s.error) {
|
|
259
|
+
const msg = t.testStepResult?.exception?.message ?? t.testStepResult?.message;
|
|
260
|
+
if (msg)
|
|
261
|
+
s.error = String(msg).split('\n')[0].slice(0, 500);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else if (env.testCaseFinished) {
|
|
265
|
+
const t = env.testCaseFinished;
|
|
266
|
+
const s = started.get(t.testCaseStartedId);
|
|
267
|
+
const pickle = s ? pickles.get(testCases.get(s.testCaseId)?.pickleId ?? '') : undefined;
|
|
268
|
+
if (!s || !pickle)
|
|
269
|
+
continue;
|
|
270
|
+
const m = /^\[([^\]]+)\]:(.*)$/.exec(pickle.uri);
|
|
271
|
+
const scenario = pickle.astNodeIds.map((id) => scenarioLines.get(id)).find(Boolean);
|
|
272
|
+
const row = pickle.astNodeIds.map((id) => exampleRows.get(id)).find((x) => x != null);
|
|
273
|
+
const finishedAt = tsToIso(t.timestamp);
|
|
274
|
+
out.push({
|
|
275
|
+
runnerProject: m?.[1] ?? 'unknown',
|
|
276
|
+
uri: (m?.[2] ?? pickle.uri).replace(/\\/g, '/'),
|
|
277
|
+
line: scenario?.line,
|
|
278
|
+
name: scenario?.name || pickle.name,
|
|
279
|
+
exampleIndex: row ?? null,
|
|
280
|
+
attempt: s.attempt,
|
|
281
|
+
// The worst step result wins, as in Cucumber: a scenario that called test.skip() part-way
|
|
282
|
+
// has PASSED steps before the SKIPPED ones, and it was skipped, not passed.
|
|
283
|
+
status: s.statuses.includes('failed')
|
|
284
|
+
? 'failed'
|
|
285
|
+
: s.statuses.includes('skipped')
|
|
286
|
+
? 'skipped'
|
|
287
|
+
: s.statuses.includes('passed')
|
|
288
|
+
? 'passed'
|
|
289
|
+
: s.statuses.length
|
|
290
|
+
? 'skipped'
|
|
291
|
+
: 'unknown',
|
|
292
|
+
durationMs: s.startedAt && finishedAt
|
|
293
|
+
? Math.max(0, Date.parse(finishedAt) - Date.parse(s.startedAt))
|
|
294
|
+
: undefined,
|
|
295
|
+
startedAt: s.startedAt,
|
|
296
|
+
finishedAt,
|
|
297
|
+
error: s.error,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
function readScenarioMetas(runDir) {
|
|
304
|
+
const out = [];
|
|
305
|
+
if (!existsSync(runDir))
|
|
306
|
+
return out;
|
|
307
|
+
for (const slug of readdirSync(runDir)) {
|
|
308
|
+
const slugDir = join(runDir, slug);
|
|
309
|
+
if (!statSync(slugDir).isDirectory())
|
|
310
|
+
continue;
|
|
311
|
+
for (const fp of readdirSync(slugDir)) {
|
|
312
|
+
const fpDir = join(slugDir, fp);
|
|
313
|
+
if (!statSync(fpDir).isDirectory())
|
|
314
|
+
continue;
|
|
315
|
+
for (const r of readdirSync(fpDir)) {
|
|
316
|
+
if (!/^r\d+$/.test(r))
|
|
317
|
+
continue;
|
|
318
|
+
const file = join(fpDir, r, scenarioFiles.meta);
|
|
319
|
+
if (!existsSync(file))
|
|
320
|
+
continue;
|
|
321
|
+
let meta;
|
|
322
|
+
try {
|
|
323
|
+
meta = JSON.parse(readFileSync(file, 'utf8'));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!meta.featureUri || !meta.runnerProject)
|
|
329
|
+
continue;
|
|
330
|
+
out.push({
|
|
331
|
+
runnerProject: meta.runnerProject,
|
|
332
|
+
uri: meta.featureUri,
|
|
333
|
+
line: meta.pickleLine,
|
|
334
|
+
name: meta.scenarioName,
|
|
335
|
+
exampleIndex: meta.exampleIndex ?? null,
|
|
336
|
+
attempt: meta.retry ?? 0,
|
|
337
|
+
status: meta.status ?? 'unknown',
|
|
338
|
+
durationMs: meta.durationMs,
|
|
339
|
+
startedAt: meta.startedAt,
|
|
340
|
+
finishedAt: meta.finishedAt,
|
|
341
|
+
error: meta.errorMessage?.split('\n')[0]?.slice(0, 500),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
// ── run selection ────────────────────────────────────────────────────────────
|
|
349
|
+
export function readRunManifest(runDir) {
|
|
350
|
+
const file = join(runDir, runFiles.manifest);
|
|
351
|
+
if (!existsSync(file))
|
|
352
|
+
return undefined;
|
|
353
|
+
try {
|
|
354
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The most recent run of `project` (and `env`, when given) under the artifacts root. Runs without a
|
|
362
|
+
* `run.json` are skipped: without it there is no way to tell which project or environment they ran.
|
|
363
|
+
*/
|
|
364
|
+
export function latestRunFor(artifactsRoot, project, env) {
|
|
365
|
+
if (!existsSync(artifactsRoot))
|
|
366
|
+
return null;
|
|
367
|
+
const candidates = [];
|
|
368
|
+
for (const runId of readdirSync(artifactsRoot)) {
|
|
369
|
+
const dir = join(artifactsRoot, runId);
|
|
370
|
+
if (!statSync(dir).isDirectory())
|
|
371
|
+
continue;
|
|
372
|
+
const m = readRunManifest(dir);
|
|
373
|
+
if (!m || m.projectSlug !== project || (env && m.env !== env))
|
|
374
|
+
continue;
|
|
375
|
+
const at = Date.parse(m.startedAt ?? '') || statSync(dir).mtimeMs;
|
|
376
|
+
candidates.push({ runId, dir, at });
|
|
377
|
+
}
|
|
378
|
+
candidates.sort((a, b) => b.at - a.at || b.runId.localeCompare(a.runId));
|
|
379
|
+
return candidates[0] ? { runId: candidates[0].runId, dir: candidates[0].dir } : null;
|
|
380
|
+
}
|
|
381
|
+
function isFailure(s) {
|
|
382
|
+
return s === 'failed' || s === 'timedOut' || s === 'interrupted';
|
|
383
|
+
}
|
|
384
|
+
function collapseAttempts(attempts) {
|
|
385
|
+
const groups = new Map();
|
|
386
|
+
for (const a of attempts) {
|
|
387
|
+
const key = `${a.runnerProject}|${a.exampleIndex}`;
|
|
388
|
+
const list = groups.get(key) ?? [];
|
|
389
|
+
list.push(a);
|
|
390
|
+
groups.set(key, list);
|
|
391
|
+
}
|
|
392
|
+
const out = [];
|
|
393
|
+
for (const list of groups.values()) {
|
|
394
|
+
list.sort((a, b) => a.attempt - b.attempt);
|
|
395
|
+
const last = list[list.length - 1];
|
|
396
|
+
const parts = parseRunnerProjectName(last.runnerProject);
|
|
397
|
+
out.push({
|
|
398
|
+
runnerProject: last.runnerProject,
|
|
399
|
+
...(parts?.layer ? { layer: parts.layer } : {}),
|
|
400
|
+
...(parts?.browser ? { browser: parts.browser } : {}),
|
|
401
|
+
exampleIndex: last.exampleIndex,
|
|
402
|
+
status: last.status,
|
|
403
|
+
attempts: list.length,
|
|
404
|
+
flaky: last.status === 'passed' && list.some((a) => isFailure(a.status)),
|
|
405
|
+
...(last.durationMs != null ? { durationMs: last.durationMs } : {}),
|
|
406
|
+
...(last.startedAt ? { startedAt: last.startedAt } : {}),
|
|
407
|
+
...(last.finishedAt ? { finishedAt: last.finishedAt } : {}),
|
|
408
|
+
...(last.error && isFailure(last.status) ? { error: last.error } : {}),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return out.sort((a, b) => a.runnerProject.localeCompare(b.runnerProject) ||
|
|
412
|
+
(a.exampleIndex ?? 0) - (b.exampleIndex ?? 0));
|
|
413
|
+
}
|
|
414
|
+
function scenarioStatus(results) {
|
|
415
|
+
if (!results.length)
|
|
416
|
+
return 'not-run';
|
|
417
|
+
if (results.some((r) => isFailure(r.status)))
|
|
418
|
+
return 'failed';
|
|
419
|
+
// A scenario excluded on one engine (`@skip:webkit`) and passing on the others has passed.
|
|
420
|
+
if (results.some((r) => r.status === 'passed'))
|
|
421
|
+
return 'passed';
|
|
422
|
+
return 'skipped';
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* A requirement passes only when every scenario covering it passed in the run. Skipped or missing
|
|
426
|
+
* results verify nothing, so they make it `not-run` rather than `passed`.
|
|
427
|
+
*/
|
|
428
|
+
function requirementStatus(scenarios) {
|
|
429
|
+
if (!scenarios.length)
|
|
430
|
+
return 'not-covered';
|
|
431
|
+
if (scenarios.some((s) => s.status === 'failed'))
|
|
432
|
+
return 'failed';
|
|
433
|
+
if (scenarios.every((s) => s.status === 'passed'))
|
|
434
|
+
return 'passed';
|
|
435
|
+
return 'not-run';
|
|
436
|
+
}
|
|
437
|
+
const byId = (a, b) => a.localeCompare(b, 'en', { numeric: true });
|
|
438
|
+
export async function buildTraceabilityReport(opts) {
|
|
439
|
+
const { project } = opts;
|
|
440
|
+
const declared = projectRequirements(project);
|
|
441
|
+
const scenarios = traceScenarios(project, opts.files);
|
|
442
|
+
const results = opts.run ? await readRunResults(opts.run.dir) : null;
|
|
443
|
+
const manifest = opts.run ? readRunManifest(opts.run.dir) : undefined;
|
|
444
|
+
// Index attempts by scenario line; the path is matched by suffix because the messages record it
|
|
445
|
+
// relative to the repository root and meta.json relative to the project root.
|
|
446
|
+
const byLine = new Map();
|
|
447
|
+
const byName = new Map();
|
|
448
|
+
for (const a of results?.attempts ?? []) {
|
|
449
|
+
const parts = parseRunnerProjectName(a.runnerProject);
|
|
450
|
+
if (parts?.project && parts.project !== project.slug)
|
|
451
|
+
continue;
|
|
452
|
+
if (a.line != null) {
|
|
453
|
+
const list = byLine.get(a.line) ?? [];
|
|
454
|
+
list.push(a);
|
|
455
|
+
byLine.set(a.line, list);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const list = byName.get(a.name) ?? [];
|
|
459
|
+
list.push(a);
|
|
460
|
+
byName.set(a.name, list);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const samePath = (uri, feature) => uri === feature || uri.endsWith(`/${feature}`);
|
|
464
|
+
const traced = scenarios.map((sc) => {
|
|
465
|
+
const attempts = [...(byLine.get(sc.line) ?? []), ...(byName.get(sc.name) ?? [])].filter((a) => samePath(a.uri, sc.feature));
|
|
466
|
+
const res = collapseAttempts(attempts);
|
|
467
|
+
return {
|
|
468
|
+
feature: sc.feature,
|
|
469
|
+
featureName: sc.featureName,
|
|
470
|
+
line: sc.line,
|
|
471
|
+
name: sc.name,
|
|
472
|
+
tags: sc.tags,
|
|
473
|
+
requirements: sc.requirements,
|
|
474
|
+
status: scenarioStatus(res),
|
|
475
|
+
results: res,
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
const ids = new Map();
|
|
479
|
+
for (const d of declared ?? [])
|
|
480
|
+
ids.set(d.id, { ...d, declared: true });
|
|
481
|
+
const tagged = [...new Set(traced.flatMap((s) => s.requirements))].sort(byId);
|
|
482
|
+
for (const id of tagged)
|
|
483
|
+
if (!ids.has(id))
|
|
484
|
+
ids.set(id, { id, declared: declared ? false : null });
|
|
485
|
+
const requirements = [...ids.values()].map((d) => {
|
|
486
|
+
const covering = traced.filter((s) => s.requirements.includes(d.id));
|
|
487
|
+
const url = requirementUrl(project, d.id);
|
|
488
|
+
return {
|
|
489
|
+
id: d.id,
|
|
490
|
+
...(d.title ? { title: d.title } : {}),
|
|
491
|
+
...(url ? { url } : {}),
|
|
492
|
+
declared: d.declared,
|
|
493
|
+
status: requirementStatus(covering),
|
|
494
|
+
scenarios: covering,
|
|
495
|
+
};
|
|
496
|
+
});
|
|
497
|
+
const count = (s) => requirements.filter((r) => r.status === s).length;
|
|
498
|
+
const untraced = traced.filter((s) => !s.requirements.length);
|
|
499
|
+
const notes = [];
|
|
500
|
+
if (!opts.run)
|
|
501
|
+
notes.push('No run selected: every scenario is reported as not run.');
|
|
502
|
+
else if (results?.source === 'none')
|
|
503
|
+
notes.push(`Run ${opts.run.runId} has no scenario results (no messages.ndjson or meta.json).`);
|
|
504
|
+
else if (results?.source === 'scenario-meta')
|
|
505
|
+
notes.push('Results were read from per-scenario meta.json because the run has no messages.ndjson; a run across several browsers may show only one of them per scenario.');
|
|
506
|
+
if (!declared)
|
|
507
|
+
notes.push('No traceability.requirements file is configured: requirements that no scenario covers cannot be listed.');
|
|
508
|
+
notes.push('A requirement passes only when every scenario that covers it passed in this run; skipped or missing results count as not run.');
|
|
509
|
+
notes.push('The sign-off block is filled in by a person. SDODS never writes it: sign-off authority is human.');
|
|
510
|
+
return {
|
|
511
|
+
kind: 'sdods-traceability',
|
|
512
|
+
version: 1,
|
|
513
|
+
project: project.slug,
|
|
514
|
+
projectName: project.name,
|
|
515
|
+
generatedAt: (opts.now ?? new Date()).toISOString(),
|
|
516
|
+
...(project.traceability?.requirements
|
|
517
|
+
? { requirementsFile: project.traceability.requirements }
|
|
518
|
+
: {}),
|
|
519
|
+
run: opts.run
|
|
520
|
+
? {
|
|
521
|
+
id: opts.run.runId,
|
|
522
|
+
env: manifest?.env,
|
|
523
|
+
trigger: manifest?.trigger,
|
|
524
|
+
command: manifest?.command,
|
|
525
|
+
tagsExpr: manifest?.tagsExpr,
|
|
526
|
+
startedAt: manifest?.startedAt,
|
|
527
|
+
finishedAt: manifest?.finishedAt,
|
|
528
|
+
exitCode: manifest?.exitCode,
|
|
529
|
+
git: manifest?.git,
|
|
530
|
+
ci: manifest?.ci,
|
|
531
|
+
sdodsVersion: manifest?.sdodsVersion,
|
|
532
|
+
playwrightVersion: manifest?.playwrightVersion,
|
|
533
|
+
resultsSource: results?.source ?? 'none',
|
|
534
|
+
}
|
|
535
|
+
: null,
|
|
536
|
+
requirements,
|
|
537
|
+
untraced: untraced.map((s) => ({
|
|
538
|
+
feature: s.feature,
|
|
539
|
+
line: s.line,
|
|
540
|
+
name: s.name,
|
|
541
|
+
status: s.status,
|
|
542
|
+
})),
|
|
543
|
+
summary: {
|
|
544
|
+
requirements: requirements.length,
|
|
545
|
+
covered: requirements.filter((r) => r.scenarios.length > 0).length,
|
|
546
|
+
passed: count('passed'),
|
|
547
|
+
failed: count('failed'),
|
|
548
|
+
notRun: count('not-run'),
|
|
549
|
+
notCovered: declared ? count('not-covered') : null,
|
|
550
|
+
undeclared: requirements.filter((r) => r.declared === false).length,
|
|
551
|
+
scenarios: traced.length,
|
|
552
|
+
tracedScenarios: traced.length - untraced.length,
|
|
553
|
+
untracedScenarios: untraced.length,
|
|
554
|
+
},
|
|
555
|
+
signOff: { signedBy: null, role: null, date: null, decision: null, notes: null },
|
|
556
|
+
notes,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
//# sourceMappingURL=traceability.js.map
|
package/dist/api/json-path.d.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read a value by dotted path (`data.items[0].id`) or JSONPath (`$.data[?(@.id==1)].name`, `$..id`).
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* `undefined` always means "nothing matched", whatever the path syntax, so callers can tell a
|
|
5
|
+
* missing key from a present one. A key that is present with the value `null` or `[]` returns that
|
|
6
|
+
* value. Dotted paths return a single value. JSONPath returns `undefined` for no match, the value
|
|
7
|
+
* itself for one match, and an array of the matches for two or more; so `$.items[*]` over an empty
|
|
8
|
+
* `items` is `undefined`, while `$.items` is `[]`.
|
|
4
9
|
*/
|
|
5
10
|
export declare function getPath(body: unknown, path: string): unknown;
|
|
11
|
+
/**
|
|
12
|
+
* The value at `path`, or an SdodsError when the path matched nothing. `getPath` returns
|
|
13
|
+
* `undefined` only for "no match", so a present `null` or `[]` passes through.
|
|
14
|
+
*/
|
|
15
|
+
export declare function matchedPath(body: unknown, path: string, savingAs?: string): unknown;
|
|
6
16
|
/** Coerce a Gherkin string literal into a comparable JS value ("42" → 42, "true" → true, "null" → null). */
|
|
7
17
|
export declare function coerce(raw: string): unknown;
|
|
8
18
|
//# sourceMappingURL=json-path.d.ts.map
|
package/dist/api/json-path.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { JSONPath } from 'jsonpath-plus';
|
|
2
|
+
import { SdodsError } from '../errors.js';
|
|
2
3
|
/**
|
|
3
4
|
* Read a value by dotted path (`data.items[0].id`) or JSONPath (`$.data[?(@.id==1)].name`, `$..id`).
|
|
4
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* `undefined` always means "nothing matched", whatever the path syntax, so callers can tell a
|
|
7
|
+
* missing key from a present one. A key that is present with the value `null` or `[]` returns that
|
|
8
|
+
* value. Dotted paths return a single value. JSONPath returns `undefined` for no match, the value
|
|
9
|
+
* itself for one match, and an array of the matches for two or more; so `$.items[*]` over an empty
|
|
10
|
+
* `items` is `undefined`, while `$.items` is `[]`.
|
|
5
11
|
*/
|
|
6
12
|
export function getPath(body, path) {
|
|
7
13
|
if (path.startsWith('$')) {
|
|
14
|
+
// `wrap: true` always yields an array of matches: a matched `[]` comes back as `[[]]`, so an
|
|
15
|
+
// empty result is unambiguously "no match" and must not leak out as a value.
|
|
8
16
|
const result = JSONPath({ path, json: body, wrap: true });
|
|
17
|
+
if (result.length === 0)
|
|
18
|
+
return undefined;
|
|
9
19
|
return result.length === 1 ? result[0] : result;
|
|
10
20
|
}
|
|
11
21
|
const segments = path
|
|
@@ -20,6 +30,18 @@ export function getPath(body, path) {
|
|
|
20
30
|
return acc[key];
|
|
21
31
|
}, body);
|
|
22
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The value at `path`, or an SdodsError when the path matched nothing. `getPath` returns
|
|
35
|
+
* `undefined` only for "no match", so a present `null` or `[]` passes through.
|
|
36
|
+
*/
|
|
37
|
+
export function matchedPath(body, path, savingAs) {
|
|
38
|
+
const value = getPath(body, path);
|
|
39
|
+
if (value !== undefined)
|
|
40
|
+
return value;
|
|
41
|
+
throw new SdodsError('RUN_FAILED', `JSON path ${path} matched nothing in the last response${savingAs ? `; cannot save it as ${savingAs}` : ''}.`, {
|
|
42
|
+
hint: 'Check the path against the response body: a failed request often returns only an error object. A JSONPath such as $.items[*] matches nothing over an empty array; point at $.items to read the array itself.',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
23
45
|
/** Coerce a Gherkin string literal into a comparable JS value ("42" → 42, "true" → true, "null" → null). */
|
|
24
46
|
export function coerce(raw) {
|
|
25
47
|
const s = raw.trim();
|