@sdods/core 0.3.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/api/client.d.ts +62 -1
- package/dist/api/client.js +189 -27
- package/dist/api/template.d.ts +15 -2
- package/dist/api/template.js +31 -13
- package/dist/auth/capture.js +4 -3
- package/dist/auth/index.js +3 -18
- package/dist/config/defaults.js +2 -0
- package/dist/config/resolve.d.ts +5 -1
- package/dist/config/resolve.js +16 -3
- package/dist/config/runner.d.ts +26 -1
- package/dist/config/runner.js +155 -24
- package/dist/config/tags.d.ts +14 -2
- package/dist/config/tags.js +27 -7
- package/dist/fixtures/api-context.d.ts +7 -0
- package/dist/fixtures/api-context.js +7 -0
- package/dist/fixtures/auth.js +72 -5
- package/dist/fixtures/test.js +9 -2
- package/dist/heal/healer.d.ts +12 -1
- package/dist/heal/healer.js +80 -34
- package/dist/heal/strategies.d.ts +12 -2
- package/dist/heal/strategies.js +42 -0
- package/dist/lint/index.d.ts +1 -0
- package/dist/lint/index.js +44 -3
- package/dist/lint/steps.d.ts +13 -0
- package/dist/lint/steps.js +333 -0
- package/dist/load-module.d.ts +7 -0
- package/dist/load-module.js +70 -0
- package/dist/logger.d.ts +1 -0
- package/dist/logger.js +9 -3
- package/dist/steps/a11y.steps.js +10 -10
- package/dist/steps/api.steps.js +64 -27
- package/dist/steps/browser.steps.js +34 -33
- package/dist/steps/clock.steps.js +5 -5
- package/dist/steps/data.steps.js +61 -8
- package/dist/steps/db.steps.js +8 -8
- package/dist/steps/dom.steps.js +49 -49
- package/dist/steps/hybrid.steps.js +9 -5
- package/dist/steps/iframe.steps.js +9 -9
- package/dist/steps/net.steps.d.ts +12 -7
- package/dist/steps/net.steps.js +78 -83
- package/dist/steps/perf.steps.js +6 -6
- package/dist/steps/tabs.steps.js +6 -6
- package/dist/steps/ui.steps.js +94 -45
- package/dist/steps/webhook.steps.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +15 -1
- package/package.json +6 -4
package/dist/heal/healer.js
CHANGED
|
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test';
|
|
|
3
3
|
import { attachmentNames, scenarioFiles } from '@sdods/contracts';
|
|
4
4
|
import { Logger } from '../logger.js';
|
|
5
5
|
import { SdodsError } from '../errors.js';
|
|
6
|
-
import { buildCandidates } from './strategies.js';
|
|
6
|
+
import { buildCandidates, disambiguationCandidates, rolesFor } from './strategies.js';
|
|
7
7
|
/** Locator wrapper: every action goes through the healer. */
|
|
8
8
|
export class HealedLocator {
|
|
9
9
|
healer;
|
|
@@ -83,54 +83,100 @@ export class Healer {
|
|
|
83
83
|
async expectText(primary, ctx, text) {
|
|
84
84
|
await expect(await this.resolve(primary, ctx, 'assert')).toContainText(text);
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* The first of `locators`, in preference order, that matches anything. Waits up to
|
|
88
|
+
* primaryTimeoutMs for the LAST one to attach — callers list the loosest locator last, a
|
|
89
|
+
* superset of the others — so an element that renders a moment late is still judged by the
|
|
90
|
+
* most precise locator. When nothing matches, the most precise one is returned for resolve()
|
|
91
|
+
* to wait on and heal from.
|
|
92
|
+
*/
|
|
93
|
+
async prefer(...locators) {
|
|
94
|
+
const loosest = locators[locators.length - 1];
|
|
95
|
+
await loosest
|
|
96
|
+
.first()
|
|
97
|
+
.waitFor({ state: 'attached', timeout: this.deps.config.primaryTimeoutMs })
|
|
98
|
+
.catch(() => undefined);
|
|
99
|
+
for (const locator of locators)
|
|
100
|
+
if ((await locator.count()) > 0)
|
|
101
|
+
return locator;
|
|
102
|
+
return locators[0];
|
|
103
|
+
}
|
|
86
104
|
/**
|
|
87
105
|
* Wait briefly for the primary locator; on failure probe all candidates in parallel,
|
|
88
|
-
* score them and pick the best above minScore.
|
|
106
|
+
* score them and pick the best above minScore. A primary that is visible but matches several
|
|
107
|
+
* elements is healed too, for actions that have a role to prefer. Records a HealEvent whenever
|
|
108
|
+
* it heals.
|
|
89
109
|
*/
|
|
90
110
|
async resolve(primary, ctx, action) {
|
|
91
111
|
const cfg = this.deps.config;
|
|
112
|
+
const healable = cfg.enabled && cfg.actions.includes(action);
|
|
92
113
|
try {
|
|
93
114
|
await primary.first().waitFor({ state: 'visible', timeout: cfg.primaryTimeoutMs });
|
|
94
|
-
return primary;
|
|
95
115
|
}
|
|
96
116
|
catch (primaryError) {
|
|
97
|
-
if (!
|
|
117
|
+
if (!healable)
|
|
98
118
|
throw primaryError;
|
|
99
119
|
const page = primary.page();
|
|
100
|
-
const t0 = Date.now();
|
|
101
120
|
const candidates = buildCandidates(page, ctx);
|
|
102
121
|
if (candidates.length === 0)
|
|
103
122
|
throw primaryError;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
// The role filter goes first: a primary whose FIRST match is hidden (a collapsed toggle
|
|
124
|
+
// ahead of the field) never becomes visible, yet narrows to the visible element that fits.
|
|
125
|
+
return this.heal(primary, ctx, action, [...disambiguationCandidates(page, primary, ctx, action), ...candidates], { cause: primaryError });
|
|
126
|
+
}
|
|
127
|
+
// Visible is not enough. A primary matching several elements makes the action throw a
|
|
128
|
+
// strict-mode violation after resolve() has returned, where healing never sees it. Assertions
|
|
129
|
+
// and hovers have no role to prefer, and their callers take .first() when they mean "any of
|
|
130
|
+
// them", so only role-bound actions are disambiguated.
|
|
131
|
+
if (!healable || rolesFor(action).length === 0)
|
|
132
|
+
return primary;
|
|
133
|
+
const matched = await primary.count();
|
|
134
|
+
if (matched <= 1)
|
|
135
|
+
return primary;
|
|
136
|
+
const page = primary.page();
|
|
137
|
+
return this.heal(primary, ctx, action, [...disambiguationCandidates(page, primary, ctx, action), ...buildCandidates(page, ctx)], { matched });
|
|
138
|
+
}
|
|
139
|
+
async heal(primary, ctx, action, candidates, why) {
|
|
140
|
+
const cfg = this.deps.config;
|
|
141
|
+
const page = primary.page();
|
|
142
|
+
const t0 = Date.now();
|
|
143
|
+
const probes = await Promise.all(candidates.map((c) => this.probe(c, cfg.probeTimeoutMs, action)));
|
|
144
|
+
const ranked = probes.sort((a, b) => b.score - a.score);
|
|
145
|
+
// Healing an ambiguity onto another ambiguous locator would only move the strict-mode
|
|
146
|
+
// violation, so that heal accepts a unique candidate only.
|
|
147
|
+
const ambiguous = why.matched !== undefined;
|
|
148
|
+
const best = ranked.find((p) => p.score >= cfg.minScore && (!ambiguous || p.count === 1));
|
|
149
|
+
const event = {
|
|
150
|
+
fingerprint: this.deps.scenario?.fingerprint ?? '',
|
|
151
|
+
runId: this.deps.runId ?? '',
|
|
152
|
+
stepIndex: this.deps.stepIndex?.() ?? 0,
|
|
153
|
+
action,
|
|
154
|
+
description: ctx.description,
|
|
155
|
+
pageUrl: page.url(),
|
|
156
|
+
originalSelector: String(primary),
|
|
157
|
+
context: { ...ctx, name: ctx.name instanceof RegExp ? ctx.name.toString() : ctx.name },
|
|
158
|
+
strategyUsed: best ? best.strategy : null,
|
|
159
|
+
healedSelector: best ? best.selector : null,
|
|
160
|
+
candidates: ranked.map(({ locator: _l, baseScore: _b, ...rest }) => rest),
|
|
161
|
+
succeeded: Boolean(best),
|
|
162
|
+
durationMs: Date.now() - t0,
|
|
163
|
+
at: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
this.record(event);
|
|
166
|
+
const probed = ranked.map((p) => `${p.strategy}=${p.score.toFixed(2)}`).join(', ') || 'none';
|
|
167
|
+
if (!best) {
|
|
168
|
+
throw new SdodsError('HEAL_FAILED', ambiguous
|
|
169
|
+
? `"${ctx.description}" (${String(primary)}) matched ${why.matched} elements and no healing candidate narrowed it to one that fits "${action}" with a score ≥ ${cfg.minScore}.`
|
|
170
|
+
: `Could not locate "${ctx.description}" (${String(primary)}) and no healing candidate scored ≥ ${cfg.minScore}.`, {
|
|
171
|
+
hint: ambiguous
|
|
172
|
+
? `Probed: ${probed}. Make the locator specific to one element: an exact name or label, a role, or a test id.`
|
|
173
|
+
: `Probed: ${probed}. Add role/name/testId/label to the heal context or fix the locator.`,
|
|
174
|
+
details: { candidates: event.candidates },
|
|
175
|
+
cause: why.cause,
|
|
176
|
+
});
|
|
133
177
|
}
|
|
178
|
+
this.log.warn(`healed "${ctx.description}": ${String(primary)}${ambiguous ? ` (matched ${why.matched})` : ''} → ${best.selector} via ${best.strategy} (score ${best.score.toFixed(2)}, ${event.durationMs} ms). Update the page object.`);
|
|
179
|
+
return best.locator.first();
|
|
134
180
|
}
|
|
135
181
|
async probe(c, timeoutMs, action) {
|
|
136
182
|
const t0 = Date.now();
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import type { Page } from '@playwright/test';
|
|
2
|
-
import type { HealContext, HealProbe } from './types.js';
|
|
1
|
+
import type { Locator, Page } from '@playwright/test';
|
|
2
|
+
import type { AriaRole, HealAction, HealContext, HealProbe } from './types.js';
|
|
3
|
+
export declare function rolesFor(action: HealAction): AriaRole[];
|
|
4
|
+
/** `locator` narrowed to elements whose role fits `action`; `locator` itself when none is defined. */
|
|
5
|
+
export declare function withActionRole(page: Page, locator: Locator, action: HealAction): Locator;
|
|
6
|
+
/**
|
|
7
|
+
* Candidates for a primary locator that matched several elements: the primary narrowed to the
|
|
8
|
+
* roles that fit the action (and the context's own role, when it names one), as a single union.
|
|
9
|
+
* A union keeps a genuine tie ambiguous — an input and a select both labelled "Country" still
|
|
10
|
+
* count 2 for a fill — rather than letting role order break it silently.
|
|
11
|
+
*/
|
|
12
|
+
export declare function disambiguationCandidates(page: Page, primary: Locator, ctx: HealContext, action: HealAction): Omit<HealProbe, 'score' | 'count' | 'visible' | 'enabled' | 'ms'>[];
|
|
3
13
|
/** Build candidate locators from the context, ordered by base score (higher = more semantic). */
|
|
4
14
|
export declare function buildCandidates(page: Page, ctx: HealContext): Omit<HealProbe, 'score' | 'count' | 'visible' | 'enabled' | 'ms'>[];
|
|
5
15
|
//# sourceMappingURL=strategies.d.ts.map
|
package/dist/heal/strategies.js
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The roles an element must have for an action to make sense on it. `assert` and `hover` apply to
|
|
3
|
+
* anything, so they have none. A contenteditable editor has no role at all, which is why callers
|
|
4
|
+
* treat a role filter as a preference, never as the only way to reach an element.
|
|
5
|
+
*/
|
|
6
|
+
const ACTION_ROLES = {
|
|
7
|
+
fill: ['textbox', 'searchbox', 'combobox', 'spinbutton'],
|
|
8
|
+
select: ['combobox', 'listbox'],
|
|
9
|
+
check: ['checkbox', 'radio', 'switch'],
|
|
10
|
+
click: ['button', 'link', 'menuitem', 'tab', 'option', 'checkbox', 'radio', 'switch'],
|
|
11
|
+
assert: [],
|
|
12
|
+
hover: [],
|
|
13
|
+
};
|
|
14
|
+
export function rolesFor(action) {
|
|
15
|
+
return ACTION_ROLES[action];
|
|
16
|
+
}
|
|
17
|
+
/** `locator` narrowed to elements whose role fits `action`; `locator` itself when none is defined. */
|
|
18
|
+
export function withActionRole(page, locator, action) {
|
|
19
|
+
const roles = rolesFor(action);
|
|
20
|
+
if (roles.length === 0)
|
|
21
|
+
return locator;
|
|
22
|
+
return locator.and(roles.map((r) => page.getByRole(r)).reduce((union, next) => union.or(next)));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Candidates for a primary locator that matched several elements: the primary narrowed to the
|
|
26
|
+
* roles that fit the action (and the context's own role, when it names one), as a single union.
|
|
27
|
+
* A union keeps a genuine tie ambiguous — an input and a select both labelled "Country" still
|
|
28
|
+
* count 2 for a fill — rather than letting role order break it silently.
|
|
29
|
+
*/
|
|
30
|
+
export function disambiguationCandidates(page, primary, ctx, action) {
|
|
31
|
+
const roles = [...new Set([...(ctx.role ? [ctx.role] : []), ...rolesFor(action)])];
|
|
32
|
+
if (roles.length === 0)
|
|
33
|
+
return [];
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
strategy: 'role-filter',
|
|
37
|
+
selector: `${String(primary)}.and(${roles.map((r) => `getByRole('${r}')`).join('.or(')}${')'.repeat(roles.length - 1)})`,
|
|
38
|
+
locator: primary.and(roles.map((r) => page.getByRole(r)).reduce((union, next) => union.or(next))),
|
|
39
|
+
baseScore: 1.0,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
}
|
|
1
43
|
/** Build candidate locators from the context, ordered by base score (higher = more semantic). */
|
|
2
44
|
export function buildCandidates(page, ctx) {
|
|
3
45
|
const out = [];
|
package/dist/lint/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export declare function detectUndefinedSteps(project: ProjectConfig & {
|
|
|
20
20
|
message: string;
|
|
21
21
|
file?: string;
|
|
22
22
|
line?: number;
|
|
23
|
+
rule?: string;
|
|
23
24
|
}>>;
|
|
24
25
|
export declare function formatFindings(result: LintResult): string;
|
|
25
26
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/lint/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
2
|
-
import { join, relative } from 'node:path';
|
|
2
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
3
|
import { execa } from 'execa';
|
|
4
4
|
import { BROWSERS_FOR_SKIP, KNOWN_VALUE_TAGS, LAYER_TAGS, RUNNER_SPECIAL_TAGS, VALUE_TAG, taxonomyFromProject, } from '../config/tags.js';
|
|
5
5
|
import { moduleForFeature } from '../config/workspace.js';
|
|
6
6
|
import { parseFeatureFile, scenariosOf } from './gherkin.js';
|
|
7
|
+
import { checkStepAmbiguity } from './steps.js';
|
|
7
8
|
export * from './gherkin.js';
|
|
8
9
|
export function listFeatureFiles(root) {
|
|
9
10
|
const out = [];
|
|
@@ -30,8 +31,10 @@ export async function lintProject(opts) {
|
|
|
30
31
|
const errors = [];
|
|
31
32
|
const warnings = [];
|
|
32
33
|
const rel = (f) => relative(project.root, f).replace(/\\/g, '/');
|
|
34
|
+
const parsedFeatures = [];
|
|
33
35
|
for (const file of files) {
|
|
34
36
|
const parsed = parseFeatureFile(file);
|
|
37
|
+
parsedFeatures.push(parsed);
|
|
35
38
|
for (const e of parsed.errors)
|
|
36
39
|
errors.push({
|
|
37
40
|
severity: 'error',
|
|
@@ -213,16 +216,25 @@ export async function lintProject(opts) {
|
|
|
213
216
|
seen.set(key, sc.line);
|
|
214
217
|
}
|
|
215
218
|
}
|
|
219
|
+
// A phrasing matched by two definitions stops bddgen generating anything (#60).
|
|
220
|
+
const ambiguity = await checkStepAmbiguity({ project, features: parsedFeatures });
|
|
221
|
+
errors.push(...ambiguity.errors);
|
|
222
|
+
warnings.push(...ambiguity.warnings);
|
|
216
223
|
if (opts.undefinedSteps && opts.repoRoot) {
|
|
217
224
|
const found = await detectUndefinedSteps(project, opts.repoRoot);
|
|
218
|
-
for (const f of found)
|
|
225
|
+
for (const f of found) {
|
|
226
|
+
const rule = f.rule ?? 'steps/undefined';
|
|
227
|
+
// The static check above usually saw this ambiguity already; do not report it twice.
|
|
228
|
+
if (errors.some((e) => e.rule === rule && e.file === f.file && e.line === f.line))
|
|
229
|
+
continue;
|
|
219
230
|
errors.push({
|
|
220
231
|
severity: 'error',
|
|
221
|
-
rule
|
|
232
|
+
rule,
|
|
222
233
|
message: f.message,
|
|
223
234
|
file: f.file ?? '',
|
|
224
235
|
line: f.line,
|
|
225
236
|
});
|
|
237
|
+
}
|
|
226
238
|
}
|
|
227
239
|
return { errors, warnings, filesChecked: files.length };
|
|
228
240
|
}
|
|
@@ -266,6 +278,35 @@ export async function detectUndefinedSteps(project, repoRoot) {
|
|
|
266
278
|
.slice(0, 10)
|
|
267
279
|
.join(' | '),
|
|
268
280
|
});
|
|
281
|
+
// bddgen stops at the first ambiguous step. This parser only knew the missing-step block, so an
|
|
282
|
+
// ambiguity (#60) — or any other generation failure — came back as "no findings".
|
|
283
|
+
const ambiguous = /^Step: (.+?) # (.+\.feature):(\d+)(?::\d+)?$/;
|
|
284
|
+
for (let i = 0; i < lines.length; i++) {
|
|
285
|
+
const s = ambiguous.exec(lines[i].trim());
|
|
286
|
+
if (!s || !/Multiple definitions matched/.test(lines[i - 1] ?? ''))
|
|
287
|
+
continue;
|
|
288
|
+
const variants = [];
|
|
289
|
+
for (let j = i + 1; j < lines.length && lines[j].trim().startsWith('- '); j++)
|
|
290
|
+
variants.push(lines[j].trim().slice(2));
|
|
291
|
+
out.push({
|
|
292
|
+
rule: 'steps/ambiguous',
|
|
293
|
+
message: `Multiple definitions matched "${s[1]}": ${variants.join('; ')}`,
|
|
294
|
+
file: relative(project.root, resolve(repoRoot, s[2])).replace(/\\/g, '/'),
|
|
295
|
+
line: Number(s[3]),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (!out.length) {
|
|
299
|
+
const tail = text
|
|
300
|
+
.split('\n')
|
|
301
|
+
.map((l) => l.trim())
|
|
302
|
+
.filter(Boolean)
|
|
303
|
+
.slice(-5)
|
|
304
|
+
.join(' | ');
|
|
305
|
+
out.push({
|
|
306
|
+
rule: 'steps/bddgen',
|
|
307
|
+
message: `bddgen failed to generate specs (exit ${result.exitCode}): ${tail || '(no output)'}`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
269
310
|
return out;
|
|
270
311
|
}
|
|
271
312
|
export function formatFindings(result) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LintFinding, ProjectConfig } from '@sdods/contracts';
|
|
2
|
+
import type { ParsedFeature } from './gherkin.js';
|
|
3
|
+
export interface AmbiguityCheckOptions {
|
|
4
|
+
project: ProjectConfig & {
|
|
5
|
+
root: string;
|
|
6
|
+
};
|
|
7
|
+
features: ParsedFeature[];
|
|
8
|
+
}
|
|
9
|
+
export declare function checkStepAmbiguity(opts: AmbiguityCheckOptions): Promise<{
|
|
10
|
+
errors: LintFinding[];
|
|
11
|
+
warnings: LintFinding[];
|
|
12
|
+
}>;
|
|
13
|
+
//# sourceMappingURL=steps.d.ts.map
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { CucumberExpression, ParameterType, ParameterTypeRegistry, RegularExpression, } from '@cucumber/cucumber-expressions';
|
|
4
|
+
import { parseTagExpr } from '../config/tags.js';
|
|
5
|
+
import { coreStepNames, coreStepsDir, coreStepsPatterns } from '../steps/glob.js';
|
|
6
|
+
/**
|
|
7
|
+
* Ambiguous step definitions (#60).
|
|
8
|
+
*
|
|
9
|
+
* playwright-bdd refuses to generate specs when one scenario step matches more than one definition
|
|
10
|
+
* ("Multiple definitions matched scenario step"), and the core step libraries share one namespace
|
|
11
|
+
* with a project's own steps — so a phrasing a project wrote before a library shipped the same
|
|
12
|
+
* text breaks the whole suite at `sdods run`, while lint used to say "no findings".
|
|
13
|
+
*
|
|
14
|
+
* Definitions are read statically (no step file is executed, so lint stays fast and side-effect
|
|
15
|
+
* free) from exactly the files the runner hands bddgen: the core libraries minus
|
|
16
|
+
* `steps.core.exclude`, plus the project's `steps/**` and `pages/**`. They are matched against
|
|
17
|
+
* pickle step text (outline rows expanded) with the same Cucumber expression engine and the same
|
|
18
|
+
* rules bddgen uses: keywords are ignored, tag-scoped definitions are filtered by the scenario's
|
|
19
|
+
* tags, and `@skip` / `@fixme` scenarios are not matched. A pattern that is not a literal, or that
|
|
20
|
+
* names a parameter type lint cannot see, is left out rather than guessed at: this check must
|
|
21
|
+
* never fail a suite that bddgen would generate.
|
|
22
|
+
*/
|
|
23
|
+
const STEP_FNS = new Set(['Given', 'When', 'Then', 'Step']);
|
|
24
|
+
function walk(dir, out) {
|
|
25
|
+
if (!existsSync(dir))
|
|
26
|
+
return;
|
|
27
|
+
for (const name of readdirSync(dir).sort()) {
|
|
28
|
+
if (name === 'node_modules')
|
|
29
|
+
continue;
|
|
30
|
+
const p = join(dir, name);
|
|
31
|
+
if (statSync(p).isDirectory())
|
|
32
|
+
walk(p, out);
|
|
33
|
+
else if (name.endsWith('.ts') && !name.endsWith('.d.ts'))
|
|
34
|
+
out.push(p);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function literalRegExp(text) {
|
|
38
|
+
const m = /^\/(.*)\/([a-z]*)$/s.exec(text);
|
|
39
|
+
if (!m)
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
return new RegExp(m[1], m[2]);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function extract(ts, file, library) {
|
|
49
|
+
const steps = [];
|
|
50
|
+
const params = [];
|
|
51
|
+
const text = readFileSync(file, 'utf8');
|
|
52
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('.js') ? ts.ScriptKind.JS : ts.ScriptKind.TS);
|
|
53
|
+
const stringOf = (n) => {
|
|
54
|
+
if (!n)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n))
|
|
57
|
+
return n.text;
|
|
58
|
+
if (ts.isRegularExpressionLiteral(n))
|
|
59
|
+
return literalRegExp(n.text);
|
|
60
|
+
return undefined;
|
|
61
|
+
};
|
|
62
|
+
const visit = (node) => {
|
|
63
|
+
if (ts.isCallExpression(node)) {
|
|
64
|
+
const callee = node.expression;
|
|
65
|
+
const name = ts.isIdentifier(callee)
|
|
66
|
+
? callee.text
|
|
67
|
+
: ts.isPropertyAccessExpression(callee)
|
|
68
|
+
? callee.name.text
|
|
69
|
+
: undefined;
|
|
70
|
+
if (name && STEP_FNS.has(name) && node.arguments.length) {
|
|
71
|
+
const pattern = stringOf(node.arguments[0]);
|
|
72
|
+
if (pattern !== undefined) {
|
|
73
|
+
const def = {
|
|
74
|
+
pattern,
|
|
75
|
+
file,
|
|
76
|
+
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
|
|
77
|
+
library,
|
|
78
|
+
};
|
|
79
|
+
const opts = node.arguments[1];
|
|
80
|
+
if (opts && ts.isObjectLiteralExpression(opts)) {
|
|
81
|
+
for (const prop of opts.properties) {
|
|
82
|
+
if (ts.isPropertyAssignment(prop) &&
|
|
83
|
+
(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) &&
|
|
84
|
+
prop.name.text === 'tags') {
|
|
85
|
+
const tags = stringOf(prop.initializer);
|
|
86
|
+
def.tags = typeof tags === 'string' ? tags : null;
|
|
87
|
+
}
|
|
88
|
+
else if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === 'tags') {
|
|
89
|
+
def.tags = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
steps.push(def);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (name === 'defineParameterType' && node.arguments[0]) {
|
|
97
|
+
const arg = node.arguments[0];
|
|
98
|
+
if (ts.isObjectLiteralExpression(arg)) {
|
|
99
|
+
let pname;
|
|
100
|
+
const regexps = [];
|
|
101
|
+
for (const prop of arg.properties) {
|
|
102
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
|
|
103
|
+
continue;
|
|
104
|
+
if (prop.name.text === 'name') {
|
|
105
|
+
const v = stringOf(prop.initializer);
|
|
106
|
+
if (typeof v === 'string')
|
|
107
|
+
pname = v;
|
|
108
|
+
}
|
|
109
|
+
else if (prop.name.text === 'regexp') {
|
|
110
|
+
const init = prop.initializer;
|
|
111
|
+
const items = ts.isArrayLiteralExpression(init) ? [...init.elements] : [init];
|
|
112
|
+
for (const item of items) {
|
|
113
|
+
const v = stringOf(item);
|
|
114
|
+
if (v !== undefined)
|
|
115
|
+
regexps.push(v);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (pname && regexps.length)
|
|
120
|
+
params.push({ name: pname, regexps });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
ts.forEachChild(node, visit);
|
|
125
|
+
};
|
|
126
|
+
visit(sf);
|
|
127
|
+
return { steps, params };
|
|
128
|
+
}
|
|
129
|
+
/** Gherkin step id → line, across backgrounds, scenarios and rules. */
|
|
130
|
+
function stepLines(doc) {
|
|
131
|
+
const lines = new Map();
|
|
132
|
+
const children = (list) => {
|
|
133
|
+
for (const c of list) {
|
|
134
|
+
const block = c.background ?? c.scenario;
|
|
135
|
+
for (const s of block?.steps ?? [])
|
|
136
|
+
lines.set(s.id, s.location.line);
|
|
137
|
+
if (c.rule)
|
|
138
|
+
children(c.rule.children);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
if (doc.feature)
|
|
142
|
+
children(doc.feature.children);
|
|
143
|
+
return lines;
|
|
144
|
+
}
|
|
145
|
+
export async function checkStepAmbiguity(opts) {
|
|
146
|
+
const { project, features } = opts;
|
|
147
|
+
const errors = [];
|
|
148
|
+
const warnings = [];
|
|
149
|
+
const rel = (f) => relative(project.root, f).replace(/\\/g, '/');
|
|
150
|
+
const exclude = project.steps?.core?.exclude ?? [];
|
|
151
|
+
try {
|
|
152
|
+
coreStepsPatterns(exclude);
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
errors.push({
|
|
156
|
+
severity: 'error',
|
|
157
|
+
rule: 'steps/core-exclude',
|
|
158
|
+
message: `${e.message} ${e.hint ?? ''}`.trim(),
|
|
159
|
+
file: 'sdods.project.yaml',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const { ts } = await import('ts-morph');
|
|
163
|
+
const dir = coreStepsDir();
|
|
164
|
+
const coreFile = (name) => [`${name}.ts`, `${name}.js`].map((f) => join(dir, f)).find((f) => existsSync(f));
|
|
165
|
+
const defs = [];
|
|
166
|
+
const paramDefs = [];
|
|
167
|
+
const add = (file, library) => {
|
|
168
|
+
if (!file)
|
|
169
|
+
return;
|
|
170
|
+
const r = extract(ts, file, library);
|
|
171
|
+
defs.push(...r.steps);
|
|
172
|
+
paramDefs.push(...r.params);
|
|
173
|
+
};
|
|
174
|
+
add(coreFile('params'));
|
|
175
|
+
for (const lib of coreStepNames()) {
|
|
176
|
+
if (!exclude.includes(lib))
|
|
177
|
+
add(coreFile(`${lib}.steps`), lib);
|
|
178
|
+
}
|
|
179
|
+
const projectFiles = [];
|
|
180
|
+
walk(join(project.root, 'steps'), projectFiles);
|
|
181
|
+
walk(join(project.root, 'pages'), projectFiles);
|
|
182
|
+
for (const f of projectFiles)
|
|
183
|
+
add(f);
|
|
184
|
+
const registry = new ParameterTypeRegistry();
|
|
185
|
+
for (const p of paramDefs) {
|
|
186
|
+
try {
|
|
187
|
+
registry.defineParameterType(new ParameterType(p.name, p.regexps, null, (s) => s, false, false));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* already defined (a project re-declaring a core type) — the first one wins */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const usable = defs.filter((d) => {
|
|
194
|
+
try {
|
|
195
|
+
d.expression =
|
|
196
|
+
typeof d.pattern === 'string'
|
|
197
|
+
? new CucumberExpression(d.pattern, registry)
|
|
198
|
+
: new RegularExpression(d.pattern, registry);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return false; // e.g. a parameter type defined dynamically — never guess
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
const tagExpr = new Map();
|
|
206
|
+
for (const d of usable) {
|
|
207
|
+
if (typeof d.tags === 'string' && !tagExpr.has(d.tags)) {
|
|
208
|
+
try {
|
|
209
|
+
tagExpr.set(d.tags, parseTagExpr(d.tags));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
d.tags = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const describe = (d) => d.library ? `${basename(d.file)}:${d.line}` : `${rel(d.file)}:${d.line}`;
|
|
217
|
+
const originOf = (d) => (d.library ? `core library "${d.library}"` : rel(d.file));
|
|
218
|
+
// text → definitions matching it by text alone (tags applied per scenario below)
|
|
219
|
+
const byText = new Map();
|
|
220
|
+
const matching = (text) => {
|
|
221
|
+
let hit = byText.get(text);
|
|
222
|
+
if (!hit) {
|
|
223
|
+
hit = usable.filter((d) => d.expression.match(text) !== null);
|
|
224
|
+
byText.set(text, hit);
|
|
225
|
+
}
|
|
226
|
+
return hit;
|
|
227
|
+
};
|
|
228
|
+
const groups = new Map();
|
|
229
|
+
const usedAmbiguous = new Set();
|
|
230
|
+
for (const feature of features) {
|
|
231
|
+
if (feature.errors.length)
|
|
232
|
+
continue;
|
|
233
|
+
const lines = stepLines(feature.document);
|
|
234
|
+
for (const pickle of feature.pickles) {
|
|
235
|
+
const tags = pickle.tags.map((t) => t.name);
|
|
236
|
+
if (tags.includes('@skip') || tags.includes('@fixme'))
|
|
237
|
+
continue;
|
|
238
|
+
for (const step of pickle.steps) {
|
|
239
|
+
let matched = matching(step.text);
|
|
240
|
+
if (matched.length < 2)
|
|
241
|
+
continue;
|
|
242
|
+
if (tags.length) {
|
|
243
|
+
const tagged = matched.filter((d) => typeof d.tags === 'string' && tagExpr.get(d.tags).evaluate(tags));
|
|
244
|
+
matched = tagged.length ? tagged : matched.filter((d) => d.tags === undefined);
|
|
245
|
+
}
|
|
246
|
+
// A tag option lint cannot read might be what separates them: say nothing.
|
|
247
|
+
if (matched.length < 2 || matched.some((d) => d.tags === null))
|
|
248
|
+
continue;
|
|
249
|
+
const origins = [...new Set(matched.map(originOf))].sort();
|
|
250
|
+
const key = origins.join('');
|
|
251
|
+
let g = groups.get(key);
|
|
252
|
+
if (!g) {
|
|
253
|
+
g = {
|
|
254
|
+
defs: [],
|
|
255
|
+
origins,
|
|
256
|
+
libraries: [...new Set(matched.flatMap((d) => (d.library ? [d.library] : [])))].sort(),
|
|
257
|
+
occurrences: new Map(),
|
|
258
|
+
};
|
|
259
|
+
groups.set(key, g);
|
|
260
|
+
}
|
|
261
|
+
const line = lines.get(step.astNodeIds[0]) ?? 0;
|
|
262
|
+
const loc = `${rel(feature.file)}:${line}`;
|
|
263
|
+
if (!g.occurrences.has(loc))
|
|
264
|
+
g.occurrences.set(loc, { file: rel(feature.file), line, text: step.text, defs: matched });
|
|
265
|
+
for (const d of matched)
|
|
266
|
+
usedAmbiguous.add(d);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const excludeHint = (libraries) => libraries.length
|
|
271
|
+
? ` Rename or remove the project definition, or drop the library with steps.core.exclude: [${libraries.join(', ')}] in sdods.project.yaml.`
|
|
272
|
+
: ' Remove or reword one of the definitions.';
|
|
273
|
+
for (const g of groups.values()) {
|
|
274
|
+
const occ = [...g.occurrences.values()];
|
|
275
|
+
const first = occ[0];
|
|
276
|
+
const examples = occ
|
|
277
|
+
.slice(0, 3)
|
|
278
|
+
.map((o) => `"${o.text}" (${o.file}:${o.line}) → ${o.defs.map(describe).join(', ')}`)
|
|
279
|
+
.join('; ');
|
|
280
|
+
const more = occ.length > 3 ? `; and ${occ.length - 3} more` : '';
|
|
281
|
+
const where = g.origins.length === 1
|
|
282
|
+
? `more than one definition in ${g.origins[0]}`
|
|
283
|
+
: `definitions in both ${g.origins.join(' and ')}`;
|
|
284
|
+
errors.push({
|
|
285
|
+
severity: 'error',
|
|
286
|
+
rule: 'steps/ambiguous',
|
|
287
|
+
message: `${occ.length} step(s) match ${where}; bddgen fails with "Multiple definitions matched scenario step". ` +
|
|
288
|
+
`${examples}${more}.${excludeHint(g.libraries)}`,
|
|
289
|
+
file: first.file,
|
|
290
|
+
line: first.line,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
// The same phrasing defined twice with a project file involved, not used by any feature yet.
|
|
294
|
+
const dupes = new Map();
|
|
295
|
+
for (const d of usable) {
|
|
296
|
+
if (d.tags !== undefined)
|
|
297
|
+
continue;
|
|
298
|
+
const key = typeof d.pattern === 'string' ? `s:${d.pattern}` : `r:${d.pattern.source}`;
|
|
299
|
+
dupes.set(key, [...(dupes.get(key) ?? []), d]);
|
|
300
|
+
}
|
|
301
|
+
const dupGroups = new Map();
|
|
302
|
+
for (const set of dupes.values()) {
|
|
303
|
+
if (set.length < 2 || set.every((d) => d.library) || set.some((d) => usedAmbiguous.has(d)))
|
|
304
|
+
continue;
|
|
305
|
+
const origins = [...new Set(set.map(originOf))].sort();
|
|
306
|
+
const key = origins.join('');
|
|
307
|
+
const g = dupGroups.get(key) ?? {
|
|
308
|
+
origins,
|
|
309
|
+
libraries: [...new Set(set.flatMap((d) => (d.library ? [d.library] : [])))].sort(),
|
|
310
|
+
sets: [],
|
|
311
|
+
};
|
|
312
|
+
g.sets.push(set);
|
|
313
|
+
dupGroups.set(key, g);
|
|
314
|
+
}
|
|
315
|
+
for (const g of dupGroups.values()) {
|
|
316
|
+
const firstProject = g.sets[0].find((d) => !d.library);
|
|
317
|
+
const examples = g.sets
|
|
318
|
+
.slice(0, 3)
|
|
319
|
+
.map((set) => `"${String(set[0].pattern)}" at ${set.map(describe).join(', ')}`)
|
|
320
|
+
.join('; ');
|
|
321
|
+
const more = g.sets.length > 3 ? `; and ${g.sets.length - 3} more` : '';
|
|
322
|
+
warnings.push({
|
|
323
|
+
severity: 'warning',
|
|
324
|
+
rule: 'steps/duplicate',
|
|
325
|
+
message: `${g.sets.length} phrasing(s) defined in ${g.origins.join(' and ')}; the first feature to use one will fail generation. ` +
|
|
326
|
+
`${examples}${more}.${excludeHint(g.libraries)}`,
|
|
327
|
+
file: rel(firstProject.file),
|
|
328
|
+
line: firstProject.line,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return { errors, warnings };
|
|
332
|
+
}
|
|
333
|
+
//# sourceMappingURL=steps.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function importProjectModule<T = Record<string, unknown>>(file: string): Promise<T>;
|
|
2
|
+
/**
|
|
3
|
+
* A named export from a module loaded by {@link importProjectModule}. A CommonJS-format file (no
|
|
4
|
+
* `"type": "module"` in the project) arrives with its exports under `default`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function namedExport<T>(mod: Record<string, unknown>, name: string): T | undefined;
|
|
7
|
+
//# sourceMappingURL=load-module.d.ts.map
|