@sdods/core 0.17.0 → 0.19.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/api/client.js +3 -1
- package/dist/config/defaults.js +7 -1
- package/dist/config/resolve.js +6 -1
- package/dist/config/runner.d.ts +5 -0
- package/dist/config/runner.js +18 -1
- package/dist/errors.d.ts +1 -1
- package/dist/fixtures/test.js +1 -0
- package/dist/heal/healer.d.ts +9 -0
- package/dist/heal/healer.js +23 -1
- package/dist/heal/report.d.ts +17 -0
- package/dist/heal/report.js +37 -0
- package/dist/import/cucumber.d.ts +74 -0
- package/dist/import/cucumber.js +385 -0
- package/dist/import/index.d.ts +3 -0
- package/dist/import/index.js +3 -0
- package/dist/import/playwright.d.ts +12 -0
- package/dist/import/playwright.js +90 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +7 -1
- package/package.json +7 -6
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sdods import cucumber <dir>`: read a Cucumber-JS suite and plan an SDODS project from it.
|
|
3
|
+
*
|
|
4
|
+
* Read-only and side-effect free: the source config and step files are read as text, never
|
|
5
|
+
* executed (docs/adr/0001-import-existing-suites.md). The caller turns the plan into a proposal.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
8
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
9
|
+
import { CucumberExpression, ParameterType, ParameterTypeRegistry, RegularExpression, } from '@cucumber/cucumber-expressions';
|
|
10
|
+
import { parseFeatureFile } from '../lint/gherkin.js';
|
|
11
|
+
import { coreStepsDir } from '../steps/glob.js';
|
|
12
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.sdods']);
|
|
13
|
+
const LAYER_TAGS = ['@ui', '@api', '@hybrid'];
|
|
14
|
+
const DEFAULT_SUITES = ['@smoke', '@regression', '@sanity'];
|
|
15
|
+
const SDODS_VALUE_KEYS = new Set([
|
|
16
|
+
'user',
|
|
17
|
+
'data',
|
|
18
|
+
'har',
|
|
19
|
+
'env',
|
|
20
|
+
'jira',
|
|
21
|
+
'github',
|
|
22
|
+
'skip',
|
|
23
|
+
'matrix',
|
|
24
|
+
]);
|
|
25
|
+
function walk(dir, accept, out = []) {
|
|
26
|
+
for (const name of readdirSync(dir)) {
|
|
27
|
+
if (SKIP_DIRS.has(name))
|
|
28
|
+
continue;
|
|
29
|
+
const p = join(dir, name);
|
|
30
|
+
const st = statSync(p);
|
|
31
|
+
if (st.isDirectory())
|
|
32
|
+
walk(p, accept, out);
|
|
33
|
+
else if (accept(p))
|
|
34
|
+
out.push(p);
|
|
35
|
+
}
|
|
36
|
+
return out.sort();
|
|
37
|
+
}
|
|
38
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
39
|
+
/** What a step definition's body touches, by the libraries it names. */
|
|
40
|
+
export function classifyStepBody(body) {
|
|
41
|
+
const ui = /\bpage\.|\bbrowser\b|playwright|puppeteer|webdriver|selenium|\$\(|locator\(/i.test(body);
|
|
42
|
+
const api = /\bfetch\(|axios|supertest|\brequest\(|\bgot\(|\.get\(['"`]\/|\.post\(['"`]\//.test(body);
|
|
43
|
+
if (ui && !api)
|
|
44
|
+
return 'ui';
|
|
45
|
+
if (api && !ui)
|
|
46
|
+
return 'api';
|
|
47
|
+
return ui && api ? 'ui' : 'unknown';
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Step definitions in a Cucumber-JS source file: `Given('…', fn)`, `When(/…/, fn)`, `Then("…")`,
|
|
51
|
+
* and `defineStep`. Read as text; the body is everything up to the next definition.
|
|
52
|
+
*/
|
|
53
|
+
export function extractStepDefinitions(file, text) {
|
|
54
|
+
const re = /\b(Given|When|Then|defineStep|Step)\s*\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)"|`((?:\\.|[^`\\])*)`|\/((?:\\.|[^/\\\n])+)\/[a-z]*)/g;
|
|
55
|
+
const found = [];
|
|
56
|
+
for (let m = re.exec(text); m; m = re.exec(text))
|
|
57
|
+
found.push({ m });
|
|
58
|
+
return found.map(({ m }, i) => {
|
|
59
|
+
const end = i + 1 < found.length ? found[i + 1].m.index : text.length;
|
|
60
|
+
const body = text.slice(m.index, end).trimEnd();
|
|
61
|
+
const regex = m[5] !== undefined;
|
|
62
|
+
const expression = (m[2] ?? m[3] ?? m[4] ?? m[5] ?? '').replace(/\\(['"`])/g, '$1');
|
|
63
|
+
return {
|
|
64
|
+
keyword: m[1],
|
|
65
|
+
expression,
|
|
66
|
+
regex,
|
|
67
|
+
file,
|
|
68
|
+
line: lineOf(text, m.index),
|
|
69
|
+
body,
|
|
70
|
+
kind: classifyStepBody(body),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Patterns of the SDODS core step library, read from its source like the source suite's. */
|
|
75
|
+
export function coreStepExpressions(dir = coreStepsDir()) {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const name of readdirSync(dir)) {
|
|
78
|
+
if (!/\.steps\.(ts|js)$/.test(name))
|
|
79
|
+
continue;
|
|
80
|
+
const text = readFileSync(join(dir, name), 'utf8');
|
|
81
|
+
for (const def of extractStepDefinitions(name, text))
|
|
82
|
+
if (!def.regex)
|
|
83
|
+
out.push(def.expression);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function registry() {
|
|
88
|
+
const reg = new ParameterTypeRegistry();
|
|
89
|
+
const add = (name, re) => {
|
|
90
|
+
try {
|
|
91
|
+
reg.defineParameterType(new ParameterType(name, re, String, (s) => s, false, false));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* already defined */
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
add('method', /GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS/);
|
|
98
|
+
add('role', /button|link|textbox|checkbox|radio|combobox|option|menuitem|tab|heading|dialog|listbox|switch|slider|spinbutton|searchbox|cell|row|img|list|listitem|navigation|banner|main|region|alert|status|form|table|grid|gridcell|tooltip|progressbar|menu|menubar|toolbar|tree|treeitem|article|separator|group/);
|
|
99
|
+
return reg;
|
|
100
|
+
}
|
|
101
|
+
function matcher(expression, regex, reg) {
|
|
102
|
+
try {
|
|
103
|
+
const expr = regex
|
|
104
|
+
? new RegularExpression(new RegExp(expression), reg)
|
|
105
|
+
: new CucumberExpression(expression, reg);
|
|
106
|
+
return (text) => expr.match(text) !== null;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined; // an unknown custom parameter type: cannot match it, so it never matches
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Literal string values from a Cucumber config file, without running it. */
|
|
113
|
+
export function readCucumberConfig(text) {
|
|
114
|
+
const list = (key) => {
|
|
115
|
+
const m = new RegExp(`["']?${key}["']?\\s*:\\s*\\[([^\\]]*)\\]`).exec(text);
|
|
116
|
+
return m ? [...m[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((x) => x[1]) : [];
|
|
117
|
+
};
|
|
118
|
+
const tags = /["']?tags["']?\s*:\s*["'`]([^"'`]+)["'`]/.exec(text)?.[1];
|
|
119
|
+
return { paths: list('paths'), requires: [...list('require'), ...list('import')], tags };
|
|
120
|
+
}
|
|
121
|
+
const MANUAL = [
|
|
122
|
+
{ re: /\b(BeforeAll|AfterAll|BeforeStep|AfterStep|Before|After)\s*\(/g, what: 'hook' },
|
|
123
|
+
{ re: /\bsetWorldConstructor\s*\(/g, what: 'custom World' },
|
|
124
|
+
{ re: /\bdefineParameterType\s*\(/g, what: 'custom parameter type' },
|
|
125
|
+
];
|
|
126
|
+
function addTags(content, edits) {
|
|
127
|
+
const lines = content.split('\n');
|
|
128
|
+
for (const e of [...edits].sort((a, b) => b.line - a.line)) {
|
|
129
|
+
if (e.tags.length === 0)
|
|
130
|
+
continue;
|
|
131
|
+
if (e.tagLine !== undefined) {
|
|
132
|
+
lines[e.tagLine - 1] = `${lines[e.tagLine - 1].trimEnd()} ${e.tags.join(' ')}`;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const indent = /^\s*/.exec(lines[e.line - 1] ?? '')[0];
|
|
136
|
+
lines.splice(e.line - 1, 0, `${indent}${e.tags.join(' ')}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
141
|
+
export function planCucumberImport(sourceDir, opts) {
|
|
142
|
+
const warnings = [];
|
|
143
|
+
const module = opts.module ?? 'imported';
|
|
144
|
+
// 1. Config, read as text.
|
|
145
|
+
const configFile = ['cucumber.js', 'cucumber.cjs', 'cucumber.mjs', 'cucumber.json']
|
|
146
|
+
.map((f) => join(sourceDir, f))
|
|
147
|
+
.find((f) => {
|
|
148
|
+
try {
|
|
149
|
+
return statSync(f).isFile();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const config = configFile ? readCucumberConfig(readFileSync(configFile, 'utf8')) : undefined;
|
|
156
|
+
if (!configFile)
|
|
157
|
+
warnings.push('No cucumber.js/.cjs/.mjs/.json found; read every *.feature and step file.');
|
|
158
|
+
else if (config && config.paths.length === 0)
|
|
159
|
+
warnings.push(`${basename(configFile)} names no literal paths; read every *.feature under the source.`);
|
|
160
|
+
// 2. Features and step files.
|
|
161
|
+
const featureFiles = walk(sourceDir, (f) => f.endsWith('.feature'));
|
|
162
|
+
if (featureFiles.length === 0)
|
|
163
|
+
warnings.push('No .feature files found.');
|
|
164
|
+
const codeFiles = walk(sourceDir, (f) => /\.(c|m)?(j|t)s$/.test(f) && !f.endsWith('.d.ts'));
|
|
165
|
+
const sourceSteps = [];
|
|
166
|
+
const manual = [];
|
|
167
|
+
for (const file of codeFiles) {
|
|
168
|
+
const text = readFileSync(file, 'utf8');
|
|
169
|
+
if (!/@cucumber\/cucumber|cucumber/.test(text))
|
|
170
|
+
continue;
|
|
171
|
+
sourceSteps.push(...extractStepDefinitions(relative(sourceDir, file), text));
|
|
172
|
+
for (const { re, what } of MANUAL) {
|
|
173
|
+
for (const m of text.matchAll(re))
|
|
174
|
+
manual.push({
|
|
175
|
+
what: `${what} (${m[1] ?? m[0]})`,
|
|
176
|
+
file: relative(sourceDir, file),
|
|
177
|
+
line: lineOf(text, m.index),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// 3. Matchers.
|
|
182
|
+
const reg = registry();
|
|
183
|
+
const core = (opts.coreExpressions ?? coreStepExpressions())
|
|
184
|
+
.map((e) => matcher(e, false, reg))
|
|
185
|
+
.filter((m) => Boolean(m));
|
|
186
|
+
const source = sourceSteps
|
|
187
|
+
.map((s) => ({ step: s, match: matcher(s.expression, s.regex, reg) }))
|
|
188
|
+
.filter((s) => Boolean(s.match));
|
|
189
|
+
const coreHits = new Set();
|
|
190
|
+
const stubbed = new Map();
|
|
191
|
+
const undefinedSteps = new Set();
|
|
192
|
+
const allSteps = new Set();
|
|
193
|
+
const kindOf = (text) => {
|
|
194
|
+
allSteps.add(text);
|
|
195
|
+
if (core.some((m) => m(text))) {
|
|
196
|
+
coreHits.add(text);
|
|
197
|
+
return 'core';
|
|
198
|
+
}
|
|
199
|
+
const hit = source.find((s) => s.match(text));
|
|
200
|
+
if (!hit) {
|
|
201
|
+
undefinedSteps.add(text);
|
|
202
|
+
return 'none';
|
|
203
|
+
}
|
|
204
|
+
stubbed.set(`${hit.step.file}:${hit.step.line}`, hit.step);
|
|
205
|
+
return hit.step.kind;
|
|
206
|
+
};
|
|
207
|
+
// 4. Features: tag each scenario with one layer and one suite tag.
|
|
208
|
+
const features = [];
|
|
209
|
+
const mappings = [];
|
|
210
|
+
const usedTags = new Set();
|
|
211
|
+
const layers = new Set();
|
|
212
|
+
for (const file of featureFiles) {
|
|
213
|
+
const text = readFileSync(file, 'utf8');
|
|
214
|
+
const parsed = parseFeatureFile(file, text);
|
|
215
|
+
const rel = relative(sourceDir, file).split(sep).join('/');
|
|
216
|
+
if (parsed.errors.length) {
|
|
217
|
+
warnings.push(`${rel}: Gherkin parse error (${parsed.errors[0].message}); copied unchanged.`);
|
|
218
|
+
features.push({ path: `features/${module}/${rel}`, content: text, scenarios: 0 });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const feature = parsed.document.feature;
|
|
222
|
+
if (!feature)
|
|
223
|
+
continue;
|
|
224
|
+
const edits = [];
|
|
225
|
+
let count = 0;
|
|
226
|
+
const visit = (children, inherited) => {
|
|
227
|
+
for (const child of children) {
|
|
228
|
+
if (child.rule) {
|
|
229
|
+
visit(child.rule.children, [...inherited, ...child.rule.tags.map((t) => t.name)]);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const sc = child.scenario;
|
|
233
|
+
if (!sc)
|
|
234
|
+
continue;
|
|
235
|
+
count++;
|
|
236
|
+
const own = sc.tags.map((t) => t.name);
|
|
237
|
+
const exampleTags = sc.examples.flatMap((e) => e.tags.map((t) => t.name));
|
|
238
|
+
const effective = [...inherited, ...own, ...exampleTags];
|
|
239
|
+
effective.forEach((t) => usedTags.add(t));
|
|
240
|
+
const kinds = sc.steps.map((s) => kindOf(s.text));
|
|
241
|
+
const ui = kinds.includes('ui');
|
|
242
|
+
const api = kinds.includes('api');
|
|
243
|
+
const inferred = ui && api ? 'hybrid' : api ? 'api' : 'ui';
|
|
244
|
+
const hasLayer = LAYER_TAGS.find((t) => effective.includes(t));
|
|
245
|
+
const hasSuite = DEFAULT_SUITES.find((t) => effective.includes(t));
|
|
246
|
+
const add = [];
|
|
247
|
+
if (!hasLayer)
|
|
248
|
+
add.push(`@${inferred}`);
|
|
249
|
+
if (!hasSuite)
|
|
250
|
+
add.push('@regression');
|
|
251
|
+
const layer = hasLayer?.slice(1) ?? inferred;
|
|
252
|
+
layers.add(layer);
|
|
253
|
+
mappings.push({
|
|
254
|
+
feature: rel,
|
|
255
|
+
scenario: sc.name,
|
|
256
|
+
layer,
|
|
257
|
+
layerInferred: !hasLayer && !ui && !api,
|
|
258
|
+
suite: hasSuite ?? '@regression',
|
|
259
|
+
});
|
|
260
|
+
const lastTag = sc.tags.length
|
|
261
|
+
? Math.max(...sc.tags.map((t) => t.location.line))
|
|
262
|
+
: undefined;
|
|
263
|
+
edits.push({ line: sc.location.line, tagLine: lastTag, tags: add });
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
visit(feature.children, feature.tags.map((t) => t.name));
|
|
267
|
+
features.push({
|
|
268
|
+
path: `features/${module}/${rel}`,
|
|
269
|
+
content: addTags(text, edits),
|
|
270
|
+
scenarios: count,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
// Steps of a Background count too.
|
|
274
|
+
for (const file of featureFiles) {
|
|
275
|
+
const parsed = parseFeatureFile(file);
|
|
276
|
+
for (const child of parsed.document.feature?.children ?? [])
|
|
277
|
+
for (const s of child.background?.steps ?? [])
|
|
278
|
+
kindOf(s.text);
|
|
279
|
+
}
|
|
280
|
+
// 5. Tags the taxonomy does not know go to tags.extra; SDODS value-tag keys are flagged.
|
|
281
|
+
const extraTags = [...usedTags]
|
|
282
|
+
.filter((t) => !LAYER_TAGS.includes(t) && !DEFAULT_SUITES.includes(t))
|
|
283
|
+
.filter((t) => {
|
|
284
|
+
const key = /^@([a-z-]+):/.exec(t)?.[1];
|
|
285
|
+
if (key && SDODS_VALUE_KEYS.has(key)) {
|
|
286
|
+
warnings.push(`${t} uses the SDODS value-tag key "${key}:" and will be validated as one.`);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
return true;
|
|
290
|
+
})
|
|
291
|
+
.map((t) => t.slice(1))
|
|
292
|
+
.sort();
|
|
293
|
+
// 6. Stubs for steps only the source suite defines.
|
|
294
|
+
const stubs = [...stubbed.values()];
|
|
295
|
+
const stepsFile = stubs.length ? renderStubs(stubs) : undefined;
|
|
296
|
+
const plan = {
|
|
297
|
+
slug: opts.slug,
|
|
298
|
+
sourceDir,
|
|
299
|
+
features,
|
|
300
|
+
layers: ['ui', 'api', 'hybrid'].filter((l) => layers.has(l)),
|
|
301
|
+
extraTags,
|
|
302
|
+
stepsFile,
|
|
303
|
+
mappings,
|
|
304
|
+
steps: {
|
|
305
|
+
total: allSteps.size,
|
|
306
|
+
core: [...coreHits].sort(),
|
|
307
|
+
stubbed: stubs.map((s) => s.expression),
|
|
308
|
+
undefined: [...undefinedSteps].sort(),
|
|
309
|
+
},
|
|
310
|
+
manual,
|
|
311
|
+
warnings,
|
|
312
|
+
report: '',
|
|
313
|
+
};
|
|
314
|
+
if (plan.layers.length === 0)
|
|
315
|
+
plan.layers = ['ui'];
|
|
316
|
+
plan.report = renderReport(plan, configFile ? relative(sourceDir, configFile) : undefined);
|
|
317
|
+
return plan;
|
|
318
|
+
}
|
|
319
|
+
/** Parameters a step expression passes: `{…}` in a Cucumber expression, capture groups in a regex. */
|
|
320
|
+
export function argumentCount(step) {
|
|
321
|
+
if (!step.regex)
|
|
322
|
+
return (step.expression.match(/(?<!\\)\{[^}]*\}/g) ?? []).length;
|
|
323
|
+
return (step.expression.match(/(?<!\\)\((?!\?)/g) ?? []).length;
|
|
324
|
+
}
|
|
325
|
+
function renderStubs(stubs) {
|
|
326
|
+
const lines = [
|
|
327
|
+
'// Imported by `sdods import cucumber`. Each step below matched a definition in the source',
|
|
328
|
+
'// suite and nothing in the SDODS step library. Port the body, using the merged fixtures',
|
|
329
|
+
'// (page, api, data, user, pages), then delete the throw. See IMPORT-REPORT.md.',
|
|
330
|
+
];
|
|
331
|
+
const keyword = (s) => s.keyword === 'Given' || s.keyword === 'When' || s.keyword === 'Then' ? s.keyword : 'Given';
|
|
332
|
+
const used = [...new Set(stubs.map(keyword))].sort();
|
|
333
|
+
lines.push(`import { ${used.join(', ')} } from './fixtures.js';`, '');
|
|
334
|
+
for (const s of stubs) {
|
|
335
|
+
const kw = keyword(s);
|
|
336
|
+
const pattern = s.regex
|
|
337
|
+
? `/${s.expression}/`
|
|
338
|
+
: `'${s.expression.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
339
|
+
lines.push(`// from ${s.file}:${s.line}`);
|
|
340
|
+
for (const l of s.body.split('\n').slice(0, 40))
|
|
341
|
+
lines.push(`// ${l}`);
|
|
342
|
+
const args = Array.from({ length: argumentCount(s) }, (_, i) => `_arg${i + 1}: string`);
|
|
343
|
+
// playwright-bdd checks arity: the fixtures object first, then one argument per parameter.
|
|
344
|
+
lines.push(`${kw}(${pattern}, async ({}, ${args.join(', ')}) => {`.replace(', ) =>', ') =>'));
|
|
345
|
+
lines.push(` throw new Error('not ported yet: ${s.file}:${s.line}');`);
|
|
346
|
+
lines.push('});', '');
|
|
347
|
+
}
|
|
348
|
+
return lines.join('\n');
|
|
349
|
+
}
|
|
350
|
+
function renderReport(plan, configFile) {
|
|
351
|
+
const scenarios = plan.mappings.length;
|
|
352
|
+
const inferred = plan.mappings.filter((m) => m.layerInferred);
|
|
353
|
+
const out = [
|
|
354
|
+
`# Import report: ${plan.slug}`,
|
|
355
|
+
'',
|
|
356
|
+
`Imported from \`${plan.sourceDir}\` by \`sdods import cucumber\`${configFile ? `, config \`${configFile}\` (read, not run)` : ''}.`,
|
|
357
|
+
'',
|
|
358
|
+
'| | Count |',
|
|
359
|
+
'|---|---|',
|
|
360
|
+
`| Feature files | ${plan.features.length} |`,
|
|
361
|
+
`| Scenarios | ${scenarios} |`,
|
|
362
|
+
`| Distinct step texts | ${plan.steps.total} |`,
|
|
363
|
+
`| Covered by the SDODS step library | ${plan.steps.core.length} |`,
|
|
364
|
+
`| Stubbed in steps/imported.steps.ts (to port) | ${plan.steps.stubbed.length} |`,
|
|
365
|
+
`| With no definition anywhere | ${plan.steps.undefined.length} |`,
|
|
366
|
+
`| Hooks, World and parameter types to port by hand | ${plan.manual.length} |`,
|
|
367
|
+
'',
|
|
368
|
+
'## Scenarios',
|
|
369
|
+
'',
|
|
370
|
+
'| Feature | Scenario | Layer | Suite |',
|
|
371
|
+
'|---|---|---|---|',
|
|
372
|
+
...plan.mappings.map((m) => `| ${m.feature} | ${m.scenario.replace(/\|/g, '\\|')} | @${m.layer}${m.layerInferred ? ' (guessed)' : ''} | ${m.suite} |`),
|
|
373
|
+
];
|
|
374
|
+
if (inferred.length)
|
|
375
|
+
out.push('', `${inferred.length} scenario(s) had no browser or HTTP code to go by and were tagged @ui; check them.`);
|
|
376
|
+
if (plan.steps.undefined.length)
|
|
377
|
+
out.push('', '## Steps with no definition', '', ...plan.steps.undefined.map((s) => `- ${s}`));
|
|
378
|
+
if (plan.manual.length)
|
|
379
|
+
out.push('', '## Port by hand', '', ...plan.manual.map((m) => `- ${m.what} at \`${m.file}:${m.line}\``));
|
|
380
|
+
if (plan.warnings.length)
|
|
381
|
+
out.push('', '## Warnings', '', ...plan.warnings.map((w) => `- ${w}`));
|
|
382
|
+
out.push('', '## Next', '', `1. \`sdods lint -p ${plan.slug}\` (it should pass as imported).`, `2. Set the base URLs in \`envs/\`, then \`sdods run -p ${plan.slug} -l api\` / \`-l ui\`.`, '3. Port the stubs in `steps/imported.steps.ts`; each one fails with `not ported yet` until then.', '');
|
|
383
|
+
return out.join('\n');
|
|
384
|
+
}
|
|
385
|
+
//# sourceMappingURL=cucumber.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface PlaywrightImportAnalysis {
|
|
2
|
+
sourceDir: string;
|
|
3
|
+
specFiles: string[];
|
|
4
|
+
tests: number;
|
|
5
|
+
describes: number;
|
|
6
|
+
fixtures: string[];
|
|
7
|
+
pageObjects: string[];
|
|
8
|
+
configFile?: string;
|
|
9
|
+
report: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function analyzePlaywrightSuite(sourceDir: string, slug: string): PlaywrightImportAnalysis;
|
|
12
|
+
//# sourceMappingURL=playwright.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sdods import playwright <dir>`: analysis only, for now (docs/adr/0001-import-existing-suites.md).
|
|
3
|
+
*
|
|
4
|
+
* Counts what a Playwright Test suite is made of so a person can judge the port. It converts
|
|
5
|
+
* nothing: mapping imperative specs onto Gherkin gets its own design.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
8
|
+
import { join, relative } from 'node:path';
|
|
9
|
+
const SKIP = new Set([
|
|
10
|
+
'node_modules',
|
|
11
|
+
'.git',
|
|
12
|
+
'dist',
|
|
13
|
+
'build',
|
|
14
|
+
'coverage',
|
|
15
|
+
'test-results',
|
|
16
|
+
'playwright-report',
|
|
17
|
+
]);
|
|
18
|
+
function walk(dir, out = []) {
|
|
19
|
+
for (const name of readdirSync(dir)) {
|
|
20
|
+
if (SKIP.has(name))
|
|
21
|
+
continue;
|
|
22
|
+
const p = join(dir, name);
|
|
23
|
+
if (statSync(p).isDirectory())
|
|
24
|
+
walk(p, out);
|
|
25
|
+
else
|
|
26
|
+
out.push(p);
|
|
27
|
+
}
|
|
28
|
+
return out.sort();
|
|
29
|
+
}
|
|
30
|
+
export function analyzePlaywrightSuite(sourceDir, slug) {
|
|
31
|
+
const files = walk(sourceDir);
|
|
32
|
+
const rel = (f) => relative(sourceDir, f);
|
|
33
|
+
const specFiles = files.filter((f) => /\.(spec|test)\.(c|m)?(j|t)s$/.test(f)).map(rel);
|
|
34
|
+
const configFile = files.map(rel).find((f) => /^playwright\.config\.(c|m)?(j|t)s$/.test(f));
|
|
35
|
+
let tests = 0;
|
|
36
|
+
let describes = 0;
|
|
37
|
+
const fixtures = new Set();
|
|
38
|
+
const pageObjects = [];
|
|
39
|
+
for (const f of files) {
|
|
40
|
+
if (!/\.(c|m)?(j|t)s$/.test(f))
|
|
41
|
+
continue;
|
|
42
|
+
const text = readFileSync(f, 'utf8');
|
|
43
|
+
if (specFiles.includes(rel(f))) {
|
|
44
|
+
tests += [...text.matchAll(/\btest(\.only|\.skip|\.fixme)?\s*\(\s*['"`]/g)].length;
|
|
45
|
+
describes += [...text.matchAll(/\btest\.describe(\.\w+)?\s*\(/g)].length;
|
|
46
|
+
}
|
|
47
|
+
for (const m of text.matchAll(/\.extend<\{([^}]*)\}>/g))
|
|
48
|
+
for (const k of m[1].matchAll(/(\w+)\s*:/g))
|
|
49
|
+
fixtures.add(k[1]);
|
|
50
|
+
if (/class\s+\w+Page\b/.test(text) || /\/pages?\//.test(f))
|
|
51
|
+
pageObjects.push(rel(f));
|
|
52
|
+
}
|
|
53
|
+
const analysis = {
|
|
54
|
+
sourceDir,
|
|
55
|
+
specFiles,
|
|
56
|
+
tests,
|
|
57
|
+
describes,
|
|
58
|
+
fixtures: [...fixtures].sort(),
|
|
59
|
+
pageObjects,
|
|
60
|
+
configFile,
|
|
61
|
+
report: '',
|
|
62
|
+
};
|
|
63
|
+
analysis.report = [
|
|
64
|
+
`# Import analysis: ${slug} (Playwright)`,
|
|
65
|
+
'',
|
|
66
|
+
`Read from \`${sourceDir}\` by \`sdods import playwright\`. Nothing was converted: this command`,
|
|
67
|
+
'only analyses a Playwright suite for now (docs/adr/0001-import-existing-suites.md).',
|
|
68
|
+
'',
|
|
69
|
+
'| | Count |',
|
|
70
|
+
'|---|---|',
|
|
71
|
+
`| Spec files | ${specFiles.length} |`,
|
|
72
|
+
`| Tests | ${tests} |`,
|
|
73
|
+
`| describe blocks | ${describes} |`,
|
|
74
|
+
`| Custom fixtures | ${analysis.fixtures.length}${analysis.fixtures.length ? ` (${analysis.fixtures.join(', ')})` : ''} |`,
|
|
75
|
+
`| Page objects | ${pageObjects.length} |`,
|
|
76
|
+
`| Config | ${configFile ?? 'none found'} |`,
|
|
77
|
+
'',
|
|
78
|
+
'## Ways in today',
|
|
79
|
+
'',
|
|
80
|
+
`- Run the specs as they are on the \`recorded\` layer: \`sdods analyze <dir> --apply\` copies them into \`projects/<slug>/recorded/imported/\`, then \`sdods run -p <slug> -l recorded\`.`,
|
|
81
|
+
'- Convert one spec at a time into a feature, steps and a page object with `sdods record convert <spec>` (a proposal).',
|
|
82
|
+
'',
|
|
83
|
+
'## Spec files',
|
|
84
|
+
'',
|
|
85
|
+
...specFiles.map((f) => `- ${f}`),
|
|
86
|
+
'',
|
|
87
|
+
].join('\n');
|
|
88
|
+
return analysis;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=playwright.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -20,4 +20,5 @@ export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from '.
|
|
|
20
20
|
export { VERSION } from './version.js';
|
|
21
21
|
export * from './matrix/index.js';
|
|
22
22
|
export * from './quality/index.js';
|
|
23
|
+
export * from './import/index.js';
|
|
23
24
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -19,4 +19,5 @@ export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from '.
|
|
|
19
19
|
export { VERSION } from './version.js';
|
|
20
20
|
export * from './matrix/index.js';
|
|
21
21
|
export * from './quality/index.js';
|
|
22
|
+
export * from './import/index.js';
|
|
22
23
|
//# sourceMappingURL=index.js.map
|
package/dist/logger.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'step' | 'debug';
|
|
2
|
+
/** `scheme://user:password@host` → `scheme://user:***@host`, for config values such as a DB URL. */
|
|
3
|
+
export declare function maskUrlPassword(value: string): string;
|
|
2
4
|
export declare function isSecretKey(name: string): boolean;
|
|
3
5
|
export declare function setLogLevel(level: LogLevel): void;
|
|
4
6
|
export declare function setLogJson(enabled: boolean): void;
|
package/dist/logger.js
CHANGED
|
@@ -10,7 +10,11 @@ const ORDER = {
|
|
|
10
10
|
let globalLevel = process.env.SDODS_LOG_LEVEL || 'info';
|
|
11
11
|
let jsonMode = process.env.SDODS_LOG_JSON === '1';
|
|
12
12
|
/** Key names whose string values are credentials: config fields, header names, log fields. */
|
|
13
|
-
const SECRET_KEY = /(passw(or)?d|secret|token|api[-_]?key|[-_]key$|authorization|cookie|credential)/i;
|
|
13
|
+
const SECRET_KEY = /(passw(or)?d|passphrase|secret|token|api[-_]?key|[-_]key$|private[-_]?key|authorization|cookie|credential|pwd|[-_]pw$|^pin$|(^|[-_])otp|(^|[-_])dsn$)/i;
|
|
14
|
+
/** `scheme://user:password@host` → `scheme://user:***@host`, for config values such as a DB URL. */
|
|
15
|
+
export function maskUrlPassword(value) {
|
|
16
|
+
return value.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/:@\s]*):[^@/\s]+@/i, '$1:***@');
|
|
17
|
+
}
|
|
14
18
|
export function isSecretKey(name) {
|
|
15
19
|
return SECRET_KEY.test(name);
|
|
16
20
|
}
|
|
@@ -38,6 +42,8 @@ export function redact(value, depth = 0) {
|
|
|
38
42
|
}
|
|
39
43
|
return out;
|
|
40
44
|
}
|
|
45
|
+
if (typeof value === 'string')
|
|
46
|
+
return maskUrlPassword(value);
|
|
41
47
|
return value;
|
|
42
48
|
}
|
|
43
49
|
function ts() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdods/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "SDODS runtime: configuration, project registry, fixtures, step libraries, data providers, screenshot narratives and self-healing locators.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"funding": "https://sdods.com/sponsor/",
|
|
@@ -46,25 +46,26 @@
|
|
|
46
46
|
"playwright-bdd": ">=9"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@sdods/contracts": "0.
|
|
49
|
+
"@sdods/contracts": "0.19.0",
|
|
50
50
|
"@cucumber/cucumber-expressions": "19.0.1",
|
|
51
51
|
"@cucumber/gherkin": "^42.0.1",
|
|
52
52
|
"@cucumber/messages": "^34.2.1",
|
|
53
53
|
"@faker-js/faker": "^10.6.0",
|
|
54
|
-
"@scalar/openapi-parser": "^0.29.
|
|
54
|
+
"@scalar/openapi-parser": "^0.29.19",
|
|
55
55
|
"ajv": "^8.20.0",
|
|
56
|
+
"allure-playwright": "3.13.0",
|
|
56
57
|
"ajv-formats": "^3.0.1",
|
|
57
58
|
"csv-parse": "^7.0.3",
|
|
58
59
|
"@cucumber/tag-expressions": "^11.0.1",
|
|
59
60
|
"dotenv": "^18.0.9",
|
|
60
61
|
"execa": "^10.2.1",
|
|
61
|
-
"jsonpath-plus": "^11.2.
|
|
62
|
+
"jsonpath-plus": "^11.2.2",
|
|
62
63
|
"picocolors": "^1.1.1",
|
|
63
64
|
"ts-morph": "^28.0.0",
|
|
64
65
|
"tsx": "^4.23.15",
|
|
65
66
|
"yaml": "^2.9.1",
|
|
66
67
|
"zod": "^4.6.7",
|
|
67
|
-
"@sdods/db": "0.
|
|
68
|
+
"@sdods/db": "0.19.0"
|
|
68
69
|
},
|
|
69
70
|
"homepage": "https://sdods.com",
|
|
70
71
|
"bugs": {
|
|
@@ -72,7 +73,7 @@
|
|
|
72
73
|
},
|
|
73
74
|
"repository": {
|
|
74
75
|
"type": "git",
|
|
75
|
-
"url": "git+https://github.com/YarlisAISolutions/
|
|
76
|
+
"url": "git+https://github.com/YarlisAISolutions/sdods.git",
|
|
76
77
|
"directory": "packages/core"
|
|
77
78
|
},
|
|
78
79
|
"keywords": [
|