@sdods/core 0.7.1 → 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,486 @@
|
|
|
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 pool = project.data.userPool;
|
|
455
|
+
const spec = pool ? project.data.sources[pool.dataset] : undefined;
|
|
456
|
+
if (!pool || !spec || !('path' in spec))
|
|
457
|
+
return undefined;
|
|
458
|
+
const out = new Map();
|
|
459
|
+
for (const env of project.envs.available) {
|
|
460
|
+
let file;
|
|
461
|
+
try {
|
|
462
|
+
file = resolveDataPath(project.root, spec, env);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
out.set(env, new Set());
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
let rows = [];
|
|
469
|
+
try {
|
|
470
|
+
const text = readFileSync(file, 'utf8');
|
|
471
|
+
if (spec.type === 'csv')
|
|
472
|
+
rows = parseCsv(text, { columns: true, skip_empty_lines: true, trim: true, bom: true });
|
|
473
|
+
else {
|
|
474
|
+
const raw = spec.type === 'json' ? JSON.parse(text) : parseYaml(text);
|
|
475
|
+
rows = Array.isArray(raw) ? raw : Array.isArray(raw?.rows) ? raw.rows : [];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
rows = [];
|
|
480
|
+
}
|
|
481
|
+
// Same fallback as the lease: a row without a role column is a "standard" account.
|
|
482
|
+
out.set(env, new Set(rows.map((r) => String(r[pool.roleColumn] ?? 'standard'))));
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
//# sourceMappingURL=index.js.map
|
package/dist/steps/api.steps.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, Then, When } from '../fixtures/test.js';
|
|
4
|
-
import { coerce, getPath } from '../api/json-path.js';
|
|
4
|
+
import { coerce, getPath, matchedPath } from '../api/json-path.js';
|
|
5
5
|
import { renderJson, renderStrict } from '../api/template.js';
|
|
6
6
|
import { pollUntil } from '../api/poll.js';
|
|
7
7
|
import { loadJsonSchema, loadZodSchema, validateAgainstOpenApi, validateJsonSchema, validateZod, } from '../api/validate.js';
|
|
@@ -104,12 +104,12 @@ Then('the response JSON path {string} should match {string}', async ({ apiContex
|
|
|
104
104
|
});
|
|
105
105
|
Then('the response JSON path {string} should have {int} items', async ({ apiContext, env }, jsonPath, count) => {
|
|
106
106
|
const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
|
|
107
|
-
const actual =
|
|
107
|
+
const actual = matchedPath(apiContext.last().response.body, path);
|
|
108
108
|
expect(Array.isArray(actual) ? actual.length : -1, `JSON path ${path} length`).toBe(count);
|
|
109
109
|
});
|
|
110
110
|
Then('the response JSON path {string} should have at least {int} items', async ({ apiContext, env }, jsonPath, count) => {
|
|
111
111
|
const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
|
|
112
|
-
const actual =
|
|
112
|
+
const actual = matchedPath(apiContext.last().response.body, path);
|
|
113
113
|
expect(Array.isArray(actual) ? actual.length : -1, `JSON path ${path} length`).toBeGreaterThanOrEqual(count);
|
|
114
114
|
});
|
|
115
115
|
Then('the response body should be an array', async ({ apiContext }) => {
|
|
@@ -144,10 +144,8 @@ Then('the response should match the OpenAPI schema for {method} {string}', async
|
|
|
144
144
|
When('I save the response JSON path {string} as {string}', async ({ apiContext, env }, jsonPath, name) => {
|
|
145
145
|
// `name` is the variable being written, an identifier rather than a template.
|
|
146
146
|
const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
throw new SdodsError('RUN_FAILED', `JSON path ${path} is undefined in the last response; cannot save as {{${name}}}.`);
|
|
150
|
-
apiContext.vars.set(name, value);
|
|
147
|
+
// A present `null` or `[]` is saved as is; only a path that matched nothing fails.
|
|
148
|
+
apiContext.vars.set(name, matchedPath(apiContext.last().response.body, path, `{{${name}}}`));
|
|
151
149
|
});
|
|
152
150
|
Given('I set the variable {string} to {string}', async ({ apiContext, env }, name, value) => {
|
|
153
151
|
apiContext.vars.set(name, coerce(renderStrict(value, apiContext.vars.toObject(), env.vars)));
|
|
@@ -24,4 +24,16 @@ export declare function firstPaintedColour(chain: BackgroundLayer[]): PaintedCol
|
|
|
24
24
|
* shape of the text gives it away.
|
|
25
25
|
*/
|
|
26
26
|
export declare function looksLikeMessageKey(text: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* What counts as an untranslated key in visible text, by default:
|
|
29
|
+
* * a whole text node shaped like a catalogue key — `nav.settings`, `checkout.summary.total`
|
|
30
|
+
* (the same rule as {@link looksLikeMessageKey}: file names, hostnames and versions excluded);
|
|
31
|
+
* * an unrendered interpolation placeholder — `Hello {{name}}`;
|
|
32
|
+
* * a library's own missing marker — `[missing "fr.nav.home" translation]` (i18n-js) or
|
|
33
|
+
* `translation missing: fr.nav.home` (Rails).
|
|
34
|
+
* A project whose keys look different passes its own pattern to the `matching {string}` step,
|
|
35
|
+
* which then replaces these defaults.
|
|
36
|
+
*/
|
|
37
|
+
export declare const UNTRANSLATED_MARKERS: readonly RegExp[];
|
|
38
|
+
export declare function findUntranslated(texts: readonly string[], pattern?: RegExp): string[];
|
|
27
39
|
//# sourceMappingURL=browser.steps.d.ts.map
|