@sdods/core 0.7.2 → 0.9.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.
Files changed (94) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/a11y/audit.d.ts +88 -0
  3. package/dist/a11y/audit.js +134 -0
  4. package/dist/analyze/index.d.ts +2 -0
  5. package/dist/analyze/index.js +2 -0
  6. package/dist/analyze/traceability-render.d.ts +5 -0
  7. package/dist/analyze/traceability-render.js +254 -0
  8. package/dist/analyze/traceability.d.ts +87 -0
  9. package/dist/analyze/traceability.js +559 -0
  10. package/dist/api/json-path.d.ts +11 -1
  11. package/dist/api/json-path.js +23 -1
  12. package/dist/config/defaults.js +2 -0
  13. package/dist/config/emulation.d.ts +74 -0
  14. package/dist/config/emulation.js +154 -0
  15. package/dist/config/index.d.ts +1 -0
  16. package/dist/config/index.js +1 -0
  17. package/dist/config/resolve.js +1 -1
  18. package/dist/config/runner.js +4 -1
  19. package/dist/config/tags.d.ts +1 -1
  20. package/dist/config/tags.js +7 -0
  21. package/dist/data/index.d.ts +1 -0
  22. package/dist/data/index.js +1 -0
  23. package/dist/data/pool-capacity.d.ts +58 -0
  24. package/dist/data/pool-capacity.js +138 -0
  25. package/dist/data/user-pool.d.ts +43 -2
  26. package/dist/data/user-pool.js +170 -40
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/fixtures/api-context.d.ts +19 -1
  29. package/dist/fixtures/api-context.js +28 -1
  30. package/dist/fixtures/scenario.d.ts +11 -0
  31. package/dist/fixtures/scenario.js +15 -0
  32. package/dist/fixtures/test.js +60 -3
  33. package/dist/fixtures/types.d.ts +5 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +4 -0
  36. package/dist/lint/index.js +86 -0
  37. package/dist/load/index.d.ts +4 -0
  38. package/dist/load/index.js +4 -0
  39. package/dist/load/profile.d.ts +22 -0
  40. package/dist/load/profile.js +109 -0
  41. package/dist/load/run.d.ts +85 -0
  42. package/dist/load/run.js +255 -0
  43. package/dist/load/script.d.ts +27 -0
  44. package/dist/load/script.js +188 -0
  45. package/dist/mail/extract.d.ts +38 -0
  46. package/dist/mail/extract.js +106 -0
  47. package/dist/mail/index.d.ts +8 -0
  48. package/dist/mail/index.js +11 -0
  49. package/dist/mail/mailpit.d.ts +26 -0
  50. package/dist/mail/mailpit.js +124 -0
  51. package/dist/mail/types.d.ts +38 -0
  52. package/dist/mail/types.js +2 -0
  53. package/dist/matrix/index.d.ts +117 -0
  54. package/dist/matrix/index.js +507 -0
  55. package/dist/perf/vitals.d.ts +118 -0
  56. package/dist/perf/vitals.js +236 -0
  57. package/dist/quality/a11y-scenario.d.ts +68 -0
  58. package/dist/quality/a11y-scenario.js +63 -0
  59. package/dist/quality/gates.d.ts +54 -0
  60. package/dist/quality/gates.js +144 -0
  61. package/dist/quality/hooks.d.ts +2 -0
  62. package/dist/quality/hooks.js +38 -0
  63. package/dist/quality/index.d.ts +4 -0
  64. package/dist/quality/index.js +2 -0
  65. package/dist/quality/perf-scenario.d.ts +132 -0
  66. package/dist/quality/perf-scenario.js +241 -0
  67. package/dist/shots/baselines.d.ts +72 -0
  68. package/dist/shots/baselines.js +201 -0
  69. package/dist/shots/hooks.js +2 -2
  70. package/dist/shots/index.d.ts +1 -0
  71. package/dist/shots/index.js +1 -0
  72. package/dist/shots/narrator.d.ts +10 -2
  73. package/dist/shots/narrator.js +106 -10
  74. package/dist/shots/policy.d.ts +5 -1
  75. package/dist/shots/policy.js +2 -0
  76. package/dist/steps/a11y.steps.d.ts +18 -51
  77. package/dist/steps/a11y.steps.js +6 -107
  78. package/dist/steps/api.steps.js +5 -7
  79. package/dist/steps/browser.steps.d.ts +12 -0
  80. package/dist/steps/browser.steps.js +154 -3
  81. package/dist/steps/canvas.steps.d.ts +9 -0
  82. package/dist/steps/canvas.steps.js +331 -0
  83. package/dist/steps/data.steps.js +17 -5
  84. package/dist/steps/email.steps.d.ts +2 -0
  85. package/dist/steps/email.steps.js +182 -0
  86. package/dist/steps/hooks.steps.d.ts +1 -0
  87. package/dist/steps/hooks.steps.js +3 -1
  88. package/dist/steps/hybrid.steps.js +2 -2
  89. package/dist/steps/index.d.ts +3 -0
  90. package/dist/steps/index.js +3 -0
  91. package/dist/steps/perf.steps.d.ts +3 -129
  92. package/dist/steps/perf.steps.js +4 -227
  93. package/dist/steps/ui.steps.js +5 -0
  94. package/package.json +4 -3
@@ -0,0 +1,507 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { parse as parseCsv } from 'csv-parse/sync';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { MATRIX_NAME_RE, ROLES_MATRIX_FILE, RolesMatrixFileSchema, } from '@sdods/contracts';
6
+ import { resolveDataPath } from '../data/resolve-path.js';
7
+ import { parseFeatureFile } from '../lint/gherkin.js';
8
+ /**
9
+ * Role matrix (#118): `roles.matrix.yaml` declares, per surface, the outcome each role should see;
10
+ * a `@matrix:<name>` Scenario Outline is the template; `sdods matrix expand` writes one `Examples:`
11
+ * block per role, tagged `@user:<role>`, between marker comments.
12
+ *
13
+ * playwright-bdd has no hook for external Examples, so the rows are generated INTO the feature file
14
+ * rather than injected at bddgen time. That keeps the generated tests visible in review, in
15
+ * `sdods features list`, and to every tool that reads Gherkin, at the cost of a generator that has
16
+ * to be idempotent and must never touch rows a person wrote.
17
+ */
18
+ export { ROLES_MATRIX_FILE };
19
+ export const MATRIX_BEGIN = 'sdods:matrix:begin';
20
+ export const MATRIX_END = 'sdods:matrix:end';
21
+ const BEGIN_RE = /^\s*#\s*sdods:matrix:begin\s+(\S+)/;
22
+ const END_RE = /^\s*#\s*sdods:matrix:end\b/;
23
+ const COLUMN_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
24
+ const RESERVED_COLUMNS = new Set(['role', 'expect', 'default']);
25
+ /** Read and validate `roles.matrix.yaml`. Undefined when the project has none. */
26
+ export function loadRolesMatrices(projectRoot) {
27
+ const file = join(projectRoot, ROLES_MATRIX_FILE);
28
+ if (!existsSync(file))
29
+ return undefined;
30
+ return parseRolesMatrices(readFileSync(file, 'utf8'), file);
31
+ }
32
+ /** Validate matrix yaml text. Problems never throw: lint reports them as findings. */
33
+ export function parseRolesMatrices(text, file = ROLES_MATRIX_FILE) {
34
+ const matrices = new Map();
35
+ const problems = [];
36
+ const problem = (message, line) => problems.push({
37
+ rule: 'matrix/config',
38
+ severity: 'error',
39
+ message,
40
+ file: ROLES_MATRIX_FILE,
41
+ line,
42
+ });
43
+ let raw;
44
+ try {
45
+ raw = parseYaml(text) ?? {};
46
+ }
47
+ catch (e) {
48
+ problem(`Cannot parse YAML: ${e.message.split('\n')[0]}`);
49
+ return { file, declared: [], matrices, problems };
50
+ }
51
+ const rawMatrices = raw?.matrices;
52
+ const declared = rawMatrices && typeof rawMatrices === 'object' ? Object.keys(rawMatrices) : [];
53
+ const parsed = RolesMatrixFileSchema.safeParse(raw);
54
+ if (!parsed.success) {
55
+ for (const issue of parsed.error.issues)
56
+ problem(`${issue.path.join('.') || '<root>'}: ${issue.message}`);
57
+ return { file, declared, matrices, problems };
58
+ }
59
+ const lines = text.split(/\r?\n/);
60
+ const lineOf = (name) => {
61
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
62
+ const i = lines.findIndex((l) => new RegExp(`^\\s+['"]?${escaped}['"]?\\s*:`).test(l));
63
+ return i >= 0 ? i + 1 : undefined;
64
+ };
65
+ for (const [name, m] of Object.entries(parsed.data.matrices)) {
66
+ const line = lineOf(name);
67
+ const at = (msg) => problem(`matrices.${name}: ${msg}`, line);
68
+ const before = problems.length;
69
+ if (!MATRIX_NAME_RE.test(name))
70
+ at('a matrix name is lowercase letters, digits and dashes (it is used as @matrix:<name>).');
71
+ const dupRoles = m.roles.filter((r, i) => m.roles.indexOf(r) !== i);
72
+ if (dupRoles.length)
73
+ at(`role listed twice: ${[...new Set(dupRoles)].join(', ')}.`);
74
+ const columnsOf = (row) => Object.keys(row).filter((k) => k !== 'expect' && k !== 'default');
75
+ const columns = columnsOf(m.rows[0]);
76
+ for (const c of columns) {
77
+ if (!COLUMN_RE.test(c))
78
+ at(`column "${c}" is not a valid placeholder name.`);
79
+ if (RESERVED_COLUMNS.has(c))
80
+ at(`column "${c}" is reserved.`);
81
+ }
82
+ const rows = [];
83
+ const seen = new Map();
84
+ m.rows.forEach((row, i) => {
85
+ const where = `rows[${i}]`;
86
+ const cols = columnsOf(row);
87
+ const missing = columns.filter((c) => !cols.includes(c));
88
+ const extra = cols.filter((c) => !columns.includes(c));
89
+ if (missing.length || extra.length)
90
+ at(`${where} has columns [${cols.join(', ')}]; every row needs the columns of rows[0] [${columns.join(', ')}].`);
91
+ const values = {};
92
+ for (const c of columns)
93
+ values[c] = row[c] === undefined ? '' : String(row[c]);
94
+ const key = JSON.stringify(columns.map((c) => values[c]));
95
+ if (seen.has(key))
96
+ at(`${where} repeats rows[${seen.get(key)}].`);
97
+ else
98
+ seen.set(key, i);
99
+ const map = typeof row.expect === 'object' ? row.expect : undefined;
100
+ if (map) {
101
+ const unknown = Object.keys(map).filter((r) => !m.roles.includes(r));
102
+ if (unknown.length)
103
+ at(`${where}.expect names ${unknown.join(', ')}, not in roles [${m.roles.join(', ')}].`);
104
+ }
105
+ const expect = {};
106
+ const unresolved = [];
107
+ for (const role of m.roles) {
108
+ const value = (typeof row.expect === 'string' ? row.expect : map?.[role]) ?? row.default ?? m.default;
109
+ if (value === undefined)
110
+ unresolved.push(role);
111
+ else {
112
+ if (m.outcomes && !m.outcomes.includes(value))
113
+ at(`${where}: outcome "${value}" for ${role} is not in outcomes [${m.outcomes.join(', ')}].`);
114
+ expect[role] = value;
115
+ }
116
+ }
117
+ if (unresolved.length)
118
+ at(`${where} has no outcome for ${unresolved.join(', ')}; name them in expect or set a default.`);
119
+ rows.push({ values, expect });
120
+ });
121
+ const title = m.title ?? defaultTitle(columns);
122
+ const titleUses = placeholders(title);
123
+ for (const p of titleUses)
124
+ if (!['role', 'expect', ...columns].includes(p))
125
+ at(`title uses <${p}>, which is not a column (role, expect${columns.map((c) => `, ${c}`).join('')}).`);
126
+ // Every generated test is titled from this format, and Playwright refuses to load two tests with
127
+ // the same title. Rows are unique by their columns, so <role> plus every column makes each title
128
+ // unique; `<expect>` does not, since two roles often share an outcome.
129
+ const leftOut = ['role', ...columns].filter((c) => !titleUses.includes(c));
130
+ if (leftOut.length)
131
+ at(`title "${title}" leaves out ${leftOut.map((c) => `<${c}>`).join(', ')}; a custom title needs <role> and every column (${columns.join(', ')}), or two generated tests can share a title and Playwright refuses to load them.`);
132
+ else if (problems.length === before) {
133
+ // Adjacent placeholders can still collide (`<x><y>`: "ab"+"c" and "a"+"bc").
134
+ const seenTitles = new Set();
135
+ titles: for (const role of m.roles)
136
+ for (const row of rows) {
137
+ const rendered = title.replace(/<([^<>\s]+)>/g, (whole, p) => p === 'role' ? role : p === 'expect' ? row.expect[role] : (row.values[p] ?? whole));
138
+ if (seenTitles.has(rendered)) {
139
+ at(`title "${title}" renders "${rendered}" for more than one generated test; separate the placeholders so every title is unique.`);
140
+ break titles;
141
+ }
142
+ seenTitles.add(rendered);
143
+ }
144
+ }
145
+ if (problems.length === before)
146
+ matrices.set(name, {
147
+ name,
148
+ description: m.description,
149
+ roles: m.roles,
150
+ columns,
151
+ title,
152
+ rows,
153
+ line,
154
+ });
155
+ }
156
+ return { file, declared, matrices, problems };
157
+ }
158
+ function defaultTitle(columns) {
159
+ if (!columns.length)
160
+ return '<role> → <expect>';
161
+ return `<role>: ${columns.map((c) => `<${c}>`).join(' ')} → <expect>`;
162
+ }
163
+ function placeholders(text) {
164
+ return [...text.matchAll(/<([^<>\s]+)>/g)].map((m) => m[1]);
165
+ }
166
+ /**
167
+ * Rewrite the generated Examples of every `@matrix:<name>` outline in one feature file.
168
+ * Pure: text in, text out. Hand-written Examples and everything outside the markers are kept.
169
+ */
170
+ export function expandFeatureText(text, matrices, opts = {}) {
171
+ const file = opts.file ?? 'inline.feature';
172
+ const problems = [];
173
+ const problem = (rule, message, line) => problems.push({ rule, severity: 'error', message, file, line });
174
+ const unchanged = (examples = 0) => ({ text, changed: false, examples, problems });
175
+ if (!/@matrix:|sdods:matrix:/.test(text))
176
+ return unchanged();
177
+ const parsed = parseFeatureFile(file, text);
178
+ if (parsed.errors.length)
179
+ return unchanged();
180
+ const doc = parsed.document;
181
+ const feature = doc.feature;
182
+ if (!feature)
183
+ return unchanged();
184
+ const isMatrixTag = (t) => t.name.startsWith('@matrix:');
185
+ for (const t of feature.tags.filter(isMatrixTag))
186
+ problem('matrix/template', `${t.name} belongs on the Scenario Outline, not the Feature.`, t.location.line);
187
+ const scenarios = [];
188
+ for (const child of feature.children) {
189
+ if (child.scenario)
190
+ scenarios.push(ref(child.scenario));
191
+ if (child.rule) {
192
+ for (const t of child.rule.tags.filter(isMatrixTag))
193
+ problem('matrix/template', `${t.name} belongs on the Scenario Outline, not the Rule.`, t.location.line);
194
+ for (const c of child.rule.children)
195
+ if (c.scenario)
196
+ scenarios.push(ref(c.scenario));
197
+ }
198
+ }
199
+ scenarios.sort((a, b) => a.sc.location.line - b.sc.location.line);
200
+ // Marker pairs, in order.
201
+ const pairs = [];
202
+ let open;
203
+ for (const c of [...doc.comments].sort((a, b) => a.location.line - b.location.line)) {
204
+ const b = BEGIN_RE.exec(c.text);
205
+ if (b) {
206
+ if (open) {
207
+ problem('matrix/markers', `"# ${MATRIX_BEGIN}" without a matching end.`, open.begin);
208
+ return unchanged();
209
+ }
210
+ open = { name: b[1], begin: c.location.line };
211
+ }
212
+ else if (END_RE.test(c.text)) {
213
+ if (!open) {
214
+ problem('matrix/markers', `"# ${MATRIX_END}" without a matching begin.`, c.location.line);
215
+ return unchanged();
216
+ }
217
+ pairs.push({ ...open, end: c.location.line });
218
+ open = undefined;
219
+ }
220
+ }
221
+ if (open) {
222
+ problem('matrix/markers', `"# ${MATRIX_BEGIN}" without a matching end.`, open.begin);
223
+ return unchanged();
224
+ }
225
+ const lines = text.split(/\r?\n/);
226
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
227
+ const edits = [];
228
+ const claimed = new Set();
229
+ let examples = 0;
230
+ scenarios.forEach(({ sc }, i) => {
231
+ const next = scenarios[i + 1];
232
+ const inside = pairs.filter((p) => p.begin > sc.location.line && (!next || p.begin < next.firstLine));
233
+ const tags = sc.tags.filter(isMatrixTag);
234
+ if (!tags.length)
235
+ return;
236
+ const line = sc.location.line;
237
+ if (tags.length > 1) {
238
+ problem('matrix/template', `A scenario takes one @matrix tag; found ${tags.map((t) => t.name).join(' ')}.`, line);
239
+ inside.forEach((p) => claimed.add(p));
240
+ return;
241
+ }
242
+ const name = tags[0].name.slice('@matrix:'.length);
243
+ const matrix = matrices.get(name);
244
+ if (!matrix) {
245
+ // lint reports the unknown name on the tag itself (tags/matrix); keep the rows as they are.
246
+ inside.forEach((p) => claimed.add(p));
247
+ return;
248
+ }
249
+ const ownUser = sc.tags.find((t) => t.name.startsWith('@user:'));
250
+ if (ownUser)
251
+ problem('matrix/template', `${ownUser.name} on a @matrix outline would override every generated role; remove it.`, ownUser.location.line);
252
+ // Gherkin fills a placeholder only from the Examples block its row belongs to, so each block is
253
+ // checked on its own: the generated blocks supply role, expect and the matrix columns; every
254
+ // hand-written block must supply every placeholder itself.
255
+ const generatedExamples = (ex) => inside.some((p) => ex.location.line > p.begin && ex.location.line < p.end);
256
+ const used = [
257
+ ...new Set([
258
+ ...placeholders(sc.name),
259
+ ...sc.steps.flatMap((s) => [
260
+ ...placeholders(s.text),
261
+ ...placeholders(s.docString?.content ?? ''),
262
+ ...(s.dataTable?.rows.flatMap((r) => r.cells.flatMap((c) => placeholders(c.value))) ??
263
+ []),
264
+ ]),
265
+ ]),
266
+ ];
267
+ const generated = ['role', 'expect', ...matrix.columns];
268
+ const unknown = used.filter((p) => !generated.includes(p));
269
+ if (unknown.length)
270
+ problem('matrix/placeholder', `${unknown.map((p) => `<${p}>`).join(', ')} not provided by matrix "${name}" (columns: ${generated.join(', ')}).`, line);
271
+ for (const ex of sc.examples.filter((e) => !generatedExamples(e))) {
272
+ // A block without a table produces no rows, so nothing to fill.
273
+ if (!ex.tableHeader)
274
+ continue;
275
+ const header = ex.tableHeader.cells.map((c) => c.value);
276
+ const missing = used.filter((p) => !header.includes(p));
277
+ if (missing.length)
278
+ problem('matrix/placeholder', `Examples${ex.name ? ` "${ex.name}"` : ''} does not supply ${missing.map((p) => `<${p}>`).join(', ')}; Gherkin fills a placeholder only from the Examples block its row is in.`, ex.location.line);
279
+ }
280
+ const indent = sc.steps[0]
281
+ ? ' '.repeat(sc.steps[0].location.column - 1)
282
+ : ' '.repeat((sc.location.column ?? 1) + 1);
283
+ const block = renderBlock(matrix, indent);
284
+ examples += matrix.roles.length * matrix.rows.length;
285
+ const [first, ...rest] = inside;
286
+ if (first) {
287
+ claimed.add(first);
288
+ edits.push({
289
+ start: first.begin - 1,
290
+ deleteCount: first.end - first.begin + 1,
291
+ insert: block,
292
+ });
293
+ for (const p of rest) {
294
+ claimed.add(p);
295
+ edits.push(removal(p, lines));
296
+ }
297
+ }
298
+ else {
299
+ edits.push({ start: lastLineOf(sc, lines), deleteCount: 0, insert: ['', ...block] });
300
+ }
301
+ });
302
+ // Generated blocks whose outline lost its @matrix tag are generated rows with no source: drop them.
303
+ for (const p of pairs)
304
+ if (!claimed.has(p))
305
+ edits.push(removal(p, lines));
306
+ if (!edits.length)
307
+ return unchanged(examples);
308
+ const out = [...lines];
309
+ for (const e of edits.sort((a, b) => b.start - a.start))
310
+ out.splice(e.start, e.deleteCount, ...e.insert);
311
+ const next = out.join(eol);
312
+ return { text: next, changed: next !== text, examples, problems };
313
+ function ref(sc) {
314
+ return { sc, firstLine: Math.min(sc.location.line, ...sc.tags.map((t) => t.location.line)) };
315
+ }
316
+ }
317
+ /** Remove a marker pair and the blank line the generator put above it. */
318
+ function removal(p, lines) {
319
+ const blankAbove = p.begin >= 2 && lines[p.begin - 2].trim() === '';
320
+ const start = blankAbove ? p.begin - 2 : p.begin - 1;
321
+ return { start, deleteCount: p.end - start, insert: [] };
322
+ }
323
+ /** 0-based index just after the last line that belongs to the scenario (steps, tables, docstrings, examples). */
324
+ function lastLineOf(sc, lines) {
325
+ let last = sc.location.line;
326
+ for (const s of sc.steps) {
327
+ last = Math.max(last, s.location.line);
328
+ for (const r of s.dataTable?.rows ?? [])
329
+ last = Math.max(last, r.location.line);
330
+ if (s.docString) {
331
+ const open = s.docString.location.line;
332
+ const delimiter = s.docString.delimiter;
333
+ let close = open;
334
+ for (let i = open; i < lines.length; i++) {
335
+ if (lines[i].trim().startsWith(delimiter)) {
336
+ close = i + 1;
337
+ break;
338
+ }
339
+ }
340
+ last = Math.max(last, close);
341
+ }
342
+ }
343
+ for (const ex of sc.examples) {
344
+ last = Math.max(last, ex.location.line, ex.tableHeader?.location.line ?? 0);
345
+ for (const r of ex.tableBody)
346
+ last = Math.max(last, r.location.line);
347
+ }
348
+ return last;
349
+ }
350
+ /** The generated region for one outline: a title-format, a role tag and a table per role. */
351
+ export function renderBlock(matrix, indent) {
352
+ const header = ['role', ...matrix.columns, 'expect'];
353
+ const out = [
354
+ `${indent}# ${MATRIX_BEGIN} ${matrix.name} (generated by \`sdods matrix expand\` from ${ROLES_MATRIX_FILE}; edit the matrix, not these rows)`,
355
+ ];
356
+ matrix.roles.forEach((role, i) => {
357
+ const rows = matrix.rows.map((r) => [
358
+ role,
359
+ ...matrix.columns.map((c) => r.values[c]),
360
+ r.expect[role],
361
+ ]);
362
+ const cells = [header, ...rows].map((row) => row.map(escapeCell));
363
+ const widths = header.map((_, c) => Math.max(...cells.map((row) => row[c].length)));
364
+ if (i > 0)
365
+ out.push('');
366
+ out.push(`${indent}# title-format: ${matrix.title}`);
367
+ out.push(`${indent}@user:${role}`);
368
+ out.push(`${indent}Examples: ${role}`);
369
+ for (const row of cells)
370
+ out.push(`${indent} | ${row.map((v, c) => v.padEnd(widths[c])).join(' | ')} |`);
371
+ });
372
+ out.push(`${indent}# ${MATRIX_END} ${matrix.name}`);
373
+ return out;
374
+ }
375
+ function escapeCell(value) {
376
+ return value.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, '\\n');
377
+ }
378
+ /**
379
+ * Everything `sdods matrix expand`, `--check`, lint and the MCP tool need, computed once:
380
+ * the matrices, their project-level findings, and the before/after text of every feature that
381
+ * uses (or used to use) a matrix.
382
+ */
383
+ export function planMatrixExpansion(project, featureFiles) {
384
+ const loaded = loadRolesMatrices(project.root);
385
+ const matrices = loaded?.matrices ?? new Map();
386
+ const problems = [
387
+ ...(loaded?.problems ?? []),
388
+ ...(loaded ? checkMatrixRoles(project, matrices) : []),
389
+ ];
390
+ const files = [];
391
+ for (const file of featureFiles) {
392
+ const before = readFileSync(file, 'utf8');
393
+ if (!/@matrix:|sdods:matrix:/.test(before))
394
+ continue;
395
+ const path = relative(project.root, file).replace(/\\/g, '/');
396
+ const r = expandFeatureText(before, matrices, { file: path });
397
+ // With an invalid matrix file every name looks unknown; do not rewrite features from that.
398
+ const usable = !loaded?.problems.length;
399
+ files.push({
400
+ file,
401
+ path,
402
+ changed: usable && r.changed,
403
+ examples: r.examples,
404
+ before,
405
+ after: usable ? r.text : before,
406
+ problems: r.problems,
407
+ });
408
+ }
409
+ return { loaded, files, problems };
410
+ }
411
+ /**
412
+ * Roles the matrix names must be roles the project declares, and each needs an account to lease.
413
+ *
414
+ * The matrix does not create identities. A role with no pool row turns every generated scenario for
415
+ * that role red with a lease error that says nothing about the product, so this is surfaced before
416
+ * anything runs — per environment, because a role seeded on staging may not exist locally.
417
+ */
418
+ export function checkMatrixRoles(project, matrices) {
419
+ const out = [];
420
+ const declared = project.tags.roles;
421
+ const at = (m, rule, severity, message) => out.push({ rule, severity, message, file: ROLES_MATRIX_FILE, line: m.line });
422
+ for (const m of matrices.values()) {
423
+ if (!declared.length)
424
+ at(m, 'matrix/role', 'warning', `matrices.${m.name}: the project declares no tags.roles, so its roles cannot be checked.`);
425
+ else {
426
+ const unknown = m.roles.filter((r) => !declared.includes(r));
427
+ if (unknown.length)
428
+ at(m, 'matrix/role', 'error', `matrices.${m.name}: ${unknown.join(', ')} ${unknown.length > 1 ? 'are not declared roles' : 'is not a declared role'} (tags.roles: ${declared.join(', ')}).`);
429
+ }
430
+ }
431
+ const pool = project.data.userPool;
432
+ const used = [...new Set([...matrices.values()].flatMap((m) => m.roles))];
433
+ if (!used.length)
434
+ return out;
435
+ if (!pool) {
436
+ for (const m of matrices.values())
437
+ at(m, 'matrix/unseeded-role', 'warning', `matrices.${m.name}: the project has no data.userPool, so no @user:<role> scenario can lease an account.`);
438
+ return out;
439
+ }
440
+ const seeded = poolRolesByEnv(project);
441
+ if (!seeded)
442
+ return out; // a db/openapi dataset: not knowable without connecting
443
+ for (const m of matrices.values()) {
444
+ for (const role of m.roles) {
445
+ const missing = [...seeded].filter(([, roles]) => !roles.has(role)).map(([env]) => env);
446
+ if (missing.length)
447
+ at(m, 'matrix/unseeded-role', 'warning', `matrices.${m.name}: role "${role}" has no account in pool dataset "${pool.dataset}" for env ${missing.join(', ')}; its ${m.rows.length} generated example(s) will fail to lease there.`);
448
+ }
449
+ }
450
+ return out;
451
+ }
452
+ /** Roles present in the user-pool dataset, per available environment. Undefined if not file-backed. */
453
+ export function poolRolesByEnv(project) {
454
+ const counts = poolAccountsByEnv(project);
455
+ if (!counts)
456
+ return undefined;
457
+ return new Map([...counts].map(([env, roles]) => [env, new Set(roles.keys())]));
458
+ }
459
+ /**
460
+ * Accounts per role in the user-pool dataset, per available environment. Undefined if not
461
+ * file-backed (a db or openapi dataset is not knowable without connecting).
462
+ *
463
+ * `poolSize` is the env's `users.poolSize`, applied as the lease applies it: the dataset is sliced
464
+ * BEFORE rows are split by role, so a small value can leave a role with no account at all.
465
+ */
466
+ export function poolAccountsByEnv(project, opts = {}) {
467
+ const pool = project.data.userPool;
468
+ const spec = pool ? project.data.sources[pool.dataset] : undefined;
469
+ if (!pool || !spec || !('path' in spec))
470
+ return undefined;
471
+ const out = new Map();
472
+ for (const env of opts.envs ?? project.envs.available) {
473
+ let file;
474
+ try {
475
+ file = resolveDataPath(project.root, spec, env);
476
+ }
477
+ catch {
478
+ out.set(env, new Map());
479
+ continue;
480
+ }
481
+ let rows = [];
482
+ try {
483
+ const text = readFileSync(file, 'utf8');
484
+ if (spec.type === 'csv')
485
+ rows = parseCsv(text, { columns: true, skip_empty_lines: true, trim: true, bom: true });
486
+ else {
487
+ const raw = spec.type === 'json' ? JSON.parse(text) : parseYaml(text);
488
+ rows = Array.isArray(raw) ? raw : Array.isArray(raw?.rows) ? raw.rows : [];
489
+ }
490
+ }
491
+ catch {
492
+ rows = [];
493
+ }
494
+ const size = opts.poolSize?.(env);
495
+ if (size)
496
+ rows = rows.slice(0, size);
497
+ const roles = new Map();
498
+ // Same fallback as the lease: a row without a role column is a "standard" account.
499
+ for (const r of rows) {
500
+ const role = String(r[pool.roleColumn] ?? 'standard');
501
+ roles.set(role, (roles.get(role) ?? 0) + 1);
502
+ }
503
+ out.set(env, roles);
504
+ }
505
+ return out;
506
+ }
507
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,118 @@
1
+ import type { PerformanceMetrics } from '@sdods/contracts';
2
+ /**
3
+ * Page vitals, latency percentiles and the budgets they are judged against, shared by the explicit
4
+ * perf steps (`steps/perf.steps.ts`) and the end-of-scenario check every `@perf` scenario gets
5
+ * (`quality/hooks.ts`). Outside the step file for the reason `a11y/audit.ts` gives: importing a step
6
+ * file registers its phrases, and `steps.core.exclude: [perf]` must stay able to switch them off.
7
+ * The rules these functions keep are written up at the top of `steps/perf.steps.ts`.
8
+ */
9
+ /** The page vitals SDODS records. Each name is also its key in `perf.budgets`. */
10
+ export declare const VITAL_KEYS: readonly ["pageLoadMs", "lcpMs", "fcpMs", "ttfbMs"];
11
+ export type VitalKey = (typeof VITAL_KEYS)[number];
12
+ /** The `perf.budgets` key a sampled API latency distribution is judged against. */
13
+ export declare const API_P95_BUDGET_KEY = "apiP95Ms";
14
+ type BudgetLayer = {
15
+ budgets?: Record<string, number | undefined>;
16
+ } | undefined;
17
+ /** The slice of a resolved config these steps read. Nothing here writes config. */
18
+ export interface BudgetConfig {
19
+ project: {
20
+ perf?: BudgetLayer;
21
+ };
22
+ env: {
23
+ name?: string;
24
+ perf?: BudgetLayer;
25
+ };
26
+ }
27
+ /**
28
+ * Effective budgets, env overriding project per key.
29
+ *
30
+ * `resolveConfig` already deep-merges `env.perf` into `config.project.perf`, so on a resolved
31
+ * config the project side is the whole answer. Merging the env side again is idempotent and keeps
32
+ * this correct for a config assembled by hand. Non-numeric values are dropped rather than spread —
33
+ * an explicit `undefined` in the env layer would otherwise erase a real project budget and turn a
34
+ * configured assertion into an unconfigured one.
35
+ */
36
+ export declare function resolvedBudgets(config: BudgetConfig): Record<string, number>;
37
+ /**
38
+ * The configured budget for one key, or a hard error naming it.
39
+ *
40
+ * PROVES the assertion that follows is gated on something real. A missing budget must never
41
+ * degrade to a skip or to `Infinity`: an unconfigured budget is the single failure mode that lets
42
+ * an entire perf suite report success while asserting nothing at all.
43
+ */
44
+ export declare function requireBudget(config: BudgetConfig, key: string): number;
45
+ export declare function requireVitalKey(name: string): VitalKey;
46
+ /** One `When I record the page vitals`. `null` in `vitals` means NOT MEASURED, never "0 ms". */
47
+ export interface VitalsRecording {
48
+ url: string;
49
+ vitals: Record<VitalKey, number | null>;
50
+ /** Why a vital is null, keyed by vital. Surfaced verbatim in the failure message. */
51
+ unmeasured: Partial<Record<VitalKey, string>>;
52
+ /** The contract shape written to `sdods/perf/<step>` and ingested into `steps.perf_json`. */
53
+ metrics: PerformanceMetrics;
54
+ }
55
+ export interface LatencySample {
56
+ status: number;
57
+ ms: number;
58
+ }
59
+ export interface LatencySampleSet {
60
+ method: string;
61
+ path: string;
62
+ samples: LatencySample[];
63
+ p95Ms: number;
64
+ }
65
+ interface PerfStore {
66
+ recordings: VitalsRecording[];
67
+ sampleSets: LatencySampleSet[];
68
+ }
69
+ export declare function perfStore(key: object): PerfStore;
70
+ export declare function lastRecording(key: object): VitalsRecording;
71
+ export declare function lastSampleSet(key: object): LatencySampleSet;
72
+ /**
73
+ * Nearest-rank p95 — deterministic for a given set of samples, with no interpolation to argue
74
+ * about. Below 20 samples the nearest rank IS the maximum; the sample count travels in the
75
+ * attachment and in every failure message so a reader can see when "p95" means "slowest of five".
76
+ *
77
+ * Throws rather than returning 0 on an empty sample: a p95 of 0 slides under every budget.
78
+ */
79
+ export declare function p95Of(msValues: readonly number[]): number;
80
+ /**
81
+ * How long the LCP observer is given to settle. LCP arrives as a stream of ever-larger candidates;
82
+ * `buffered: true` replays the ones already emitted and this window catches any still in flight.
83
+ * A fixed constant rather than a computed wait, so two runs of one scenario observe the same
84
+ * window and a baseline stays comparable.
85
+ */
86
+ export declare const LCP_SETTLE_MS = 500;
87
+ /** Bounded wait for the load event before reading `loadEventEnd`. */
88
+ export declare const LOAD_WAIT_MS = 5000;
89
+ /** What the page hands back. Every timing is `number | null`; null always means "did not fire". */
90
+ export interface RawVitals {
91
+ hasNavigationEntry: boolean;
92
+ navigationStartEpochMs: number | null;
93
+ pageLoadMs: number | null;
94
+ ttfbMs: number | null;
95
+ fcpMs: number | null;
96
+ lcpMs: number | null;
97
+ domContentLoadedMs: number | null;
98
+ totalResources: number;
99
+ totalResourceSizeKB: number;
100
+ lcpSupported: boolean;
101
+ fcpSupported: boolean;
102
+ }
103
+ /**
104
+ * Runs inside the page. Serialised by Playwright, so it closes over nothing but its argument — the
105
+ * type aliases above are erased at compile time and are safe to reference; the constants are not,
106
+ * which is why the settle window is passed in.
107
+ */
108
+ export declare function collectRawVitals(settleMs: number): Promise<RawVitals>;
109
+ /**
110
+ * Turns the page's answer into a recording, capturing WHY a vital is null while the browser's
111
+ * capabilities are still in hand. By the time a `Then` step fails, "lcpMs was not measured" on its
112
+ * own sends the reader hunting for a bug in a page that is merely running in a browser that does
113
+ * not implement LCP (WebKit). Support is read from `PerformanceObserver.supportedEntryTypes`, never
114
+ * assumed from the browser name.
115
+ */
116
+ export declare function toRecording(raw: RawVitals, url: string, browserName?: string): VitalsRecording;
117
+ export {};
118
+ //# sourceMappingURL=vitals.d.ts.map