@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/config/runner.js
CHANGED
|
@@ -2,16 +2,51 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { join, resolve as resolvePath } from 'node:path';
|
|
3
3
|
import { devices } from '@playwright/test';
|
|
4
4
|
import { cucumberReporter, defineBddConfig } from 'playwright-bdd';
|
|
5
|
-
import { runnerProjectName, runFiles } from '@sdods/contracts';
|
|
5
|
+
import { EVIDENCE_DEFAULTS, RUNNER_SETUP_PHASE, runnerProjectName, runFiles, } from '@sdods/contracts';
|
|
6
6
|
import { coreStepsPatterns } from '../steps/glob.js';
|
|
7
7
|
import { combineTagExpr, normalizeTagExpr } from './tags.js';
|
|
8
8
|
const DEVICE_FOR_BROWSER = {
|
|
9
9
|
chromium: 'Desktop Chrome',
|
|
10
|
+
edge: 'Desktop Edge',
|
|
10
11
|
firefox: 'Desktop Firefox',
|
|
11
12
|
webkit: 'Desktop Safari',
|
|
12
13
|
'mobile-chrome': 'Pixel 7',
|
|
13
14
|
'mobile-safari': 'iPhone 15',
|
|
14
15
|
};
|
|
16
|
+
/**
|
|
17
|
+
* Browsers that are a branded channel of an engine rather than the bundled build.
|
|
18
|
+
*
|
|
19
|
+
* `devices['Desktop Edge']` only sets an Edge user-agent — its `defaultBrowserType` is still
|
|
20
|
+
* 'chromium' — so without an explicit channel Playwright launches the bundled Chromium and it
|
|
21
|
+
* merely claims to be Edge. The channel is what actually starts the browser the user installed.
|
|
22
|
+
*/
|
|
23
|
+
const CHANNEL_FOR_BROWSER = { edge: 'msedge' };
|
|
24
|
+
/** The Playwright channel family a project-level `channel:` belongs to, for compatibility checks. */
|
|
25
|
+
function channelFamily(channel) {
|
|
26
|
+
return channel.startsWith('msedge') ? 'msedge' : 'chrome';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The channel a run target launches with.
|
|
30
|
+
*
|
|
31
|
+
* A project-level `channel:` refines a branded browser, it does not redirect it: `channel: chrome`
|
|
32
|
+
* on a project that also runs `edge` must not make the `--edge` target launch Google Chrome, which
|
|
33
|
+
* would put a browser in the results under another browser's name.
|
|
34
|
+
*/
|
|
35
|
+
function channelFor(browser, projectChannel) {
|
|
36
|
+
const branded = CHANNEL_FOR_BROWSER[browser];
|
|
37
|
+
if (!branded) {
|
|
38
|
+
// Chromium-family engines take whatever the project asked for; other engines have no channels.
|
|
39
|
+
return browser === 'chromium' || browser === 'mobile-chrome' ? projectChannel : undefined;
|
|
40
|
+
}
|
|
41
|
+
if (projectChannel && channelFamily(projectChannel) === channelFamily(branded))
|
|
42
|
+
return projectChannel;
|
|
43
|
+
return branded;
|
|
44
|
+
}
|
|
45
|
+
/** The `use` keys that select the browser binary for a run target. */
|
|
46
|
+
function browserUse(browser, projectChannel) {
|
|
47
|
+
const channel = channelFor(browser, projectChannel);
|
|
48
|
+
return { ...devices[DEVICE_FOR_BROWSER[browser]], ...(channel ? { channel } : {}) };
|
|
49
|
+
}
|
|
15
50
|
export const DASHBOARD_REPORTER = '@sdods/core/reporters/dashboard';
|
|
16
51
|
/** Convenience for `sdods.runner.config.ts`: read the selection from SDODS_* env vars. */
|
|
17
52
|
export function selectionFromEnv(env = process.env) {
|
|
@@ -32,6 +67,7 @@ export function selectionFromEnv(env = process.env) {
|
|
|
32
67
|
allure: env.SDODS_ALLURE === '1',
|
|
33
68
|
reporters: list(env.SDODS_REPORTERS),
|
|
34
69
|
reporterMode: env.SDODS_REPORTER_MODE || 'default',
|
|
70
|
+
process: env.SDODS_PROCESS || undefined,
|
|
35
71
|
};
|
|
36
72
|
}
|
|
37
73
|
/** Names (and identity) of the Playwright projects a selection would produce, without side effects. */
|
|
@@ -93,32 +129,40 @@ export function buildRunnerConfig(registry, sel = {}) {
|
|
|
93
129
|
for (const k of Object.keys(envUse))
|
|
94
130
|
if (envUse[k] === undefined)
|
|
95
131
|
delete envUse[k];
|
|
132
|
+
const byTag = p.retries?.byTag ?? {};
|
|
133
|
+
const pushProject = (project) => projects.push(...withRetriesByTag(project, byTag));
|
|
134
|
+
const { fullyParallel, setup } = runSettings(registry, entry.slug, p, sel.process);
|
|
135
|
+
// Playwright's own artifacts, per target so each project in a multi-project run keeps its own.
|
|
136
|
+
const evidenceUse = {
|
|
137
|
+
screenshot: p.evidence.screenshot,
|
|
138
|
+
video: p.evidence.video,
|
|
139
|
+
trace: p.evidence.trace,
|
|
140
|
+
};
|
|
96
141
|
for (const layer of layers) {
|
|
97
142
|
if (layer === 'recorded') {
|
|
98
143
|
const recordedDir = join(p.root, 'recorded');
|
|
99
144
|
if (!existsSync(recordedDir))
|
|
100
145
|
continue;
|
|
101
146
|
for (const browser of browsers) {
|
|
102
|
-
|
|
147
|
+
pushProject({
|
|
103
148
|
name: runnerProjectName({ project: p.slug, layer, browser }),
|
|
104
149
|
testDir: recordedDir,
|
|
105
150
|
testMatch: '**/*.spec.ts',
|
|
151
|
+
fullyParallel,
|
|
106
152
|
snapshotPathTemplate: join(p.root, 'features', '__screenshots__', '{projectName}', '{platform}', '{arg}{ext}'),
|
|
107
153
|
use: {
|
|
108
|
-
...
|
|
109
|
-
...(p.channel && (browser === 'chromium' || browser === 'mobile-chrome')
|
|
110
|
-
? { channel: p.channel }
|
|
111
|
-
: {}),
|
|
154
|
+
...browserUse(browser, p.channel),
|
|
112
155
|
baseURL: cfg.env.ui.baseUrl,
|
|
113
156
|
testIdAttribute: p.testIdAttribute,
|
|
114
157
|
...envUse,
|
|
158
|
+
...evidenceUse,
|
|
115
159
|
sdods: { project: p.slug, layer, browser },
|
|
116
160
|
},
|
|
117
161
|
});
|
|
118
162
|
}
|
|
119
163
|
continue;
|
|
120
164
|
}
|
|
121
|
-
const
|
|
165
|
+
const generate = (tags, outDir) => defineBddConfig({
|
|
122
166
|
features: `${toPosix(p.root)}/features/**/*.feature`,
|
|
123
167
|
steps: [
|
|
124
168
|
...coreStepsPatterns(cfg.project.steps?.core?.exclude ?? []),
|
|
@@ -127,42 +171,66 @@ export function buildRunnerConfig(registry, sel = {}) {
|
|
|
127
171
|
],
|
|
128
172
|
// Each run generates into its own dir (cleaned by `sdods run`), lint/export into `.lint`,
|
|
129
173
|
// so concurrent runs and tooling never race on generated specs.
|
|
130
|
-
outputDir: `${toPosix(join(cfg.runtime.repoRoot, '.sdods/generated', sel.lint ? '.lint' : (sel.runId ?? 'adhoc'), p.slug,
|
|
174
|
+
outputDir: `${toPosix(join(cfg.runtime.repoRoot, '.sdods/generated', sel.lint ? '.lint' : (sel.runId ?? 'adhoc'), p.slug, outDir))}`,
|
|
131
175
|
featuresRoot: `${toPosix(p.root)}/features`,
|
|
132
176
|
// Explicit: scenarios that only use core steps cannot let bddgen guess the project test instance.
|
|
133
177
|
importTestFrom: `${toPosix(p.root)}/steps/fixtures.ts`,
|
|
134
178
|
disableWarnings: { importTestFrom: true },
|
|
135
|
-
tags
|
|
179
|
+
tags,
|
|
136
180
|
examplesTitleFormat: 'Example #<_index_>',
|
|
137
181
|
missingSteps: sel.lint ? 'fail-on-gen' : 'fail-on-run',
|
|
138
182
|
aiFix: { promptAttachment: true },
|
|
139
183
|
quotes: 'single',
|
|
140
184
|
});
|
|
185
|
+
const layerExpr = combineTagExpr(`@${layer}`, sel.tags);
|
|
186
|
+
// Setup scenarios are generated on their own: whatever --tags selects, the gate still runs,
|
|
187
|
+
// and a scenario that is setup never runs a second time inside the target it gates.
|
|
188
|
+
const testDir = generate(setup ? `(${layerExpr}) and not (${setup.tags})` : layerExpr, layer);
|
|
141
189
|
bddConfigs++;
|
|
190
|
+
const setupTestDir = setup
|
|
191
|
+
? generate(combineTagExpr(`@${layer}`, setup.tags), `${layer}--${RUNNER_SETUP_PHASE}`)
|
|
192
|
+
: undefined;
|
|
193
|
+
if (setupTestDir)
|
|
194
|
+
bddConfigs++;
|
|
195
|
+
/** Push a target, preceded by its setup companion when the project has a setup tier. */
|
|
196
|
+
const pushTarget = (parts, target) => {
|
|
197
|
+
const name = runnerProjectName({ project: p.slug, ...parts });
|
|
198
|
+
if (setupTestDir) {
|
|
199
|
+
const setupName = runnerProjectName({
|
|
200
|
+
project: p.slug,
|
|
201
|
+
...parts,
|
|
202
|
+
phase: RUNNER_SETUP_PHASE,
|
|
203
|
+
});
|
|
204
|
+
projects.push({ ...target, name: setupName, testDir: setupTestDir });
|
|
205
|
+
pushProject({ ...target, name, testDir, dependencies: [setupName] });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
pushProject({ ...target, name, testDir });
|
|
209
|
+
}
|
|
210
|
+
};
|
|
142
211
|
if (layer === 'api') {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
212
|
+
pushTarget({ layer }, {
|
|
213
|
+
fullyParallel,
|
|
214
|
+
use: {
|
|
215
|
+
...evidenceUse,
|
|
216
|
+
sdods: { project: p.slug, layer },
|
|
217
|
+
},
|
|
147
218
|
});
|
|
148
219
|
continue;
|
|
149
220
|
}
|
|
150
221
|
for (const browser of browsers) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
testDir,
|
|
222
|
+
pushTarget({ layer, browser }, {
|
|
223
|
+
fullyParallel,
|
|
154
224
|
// Baselines live with the project (generated specs are per-run and deleted):
|
|
155
225
|
// projects/<slug>/features/__screenshots__/<pw project>/<platform>/<name>.png
|
|
156
226
|
snapshotPathTemplate: join(p.root, 'features', '__screenshots__', '{projectName}', '{platform}', '{arg}{ext}'),
|
|
157
227
|
use: {
|
|
158
|
-
...
|
|
159
|
-
...(p.channel && (browser === 'chromium' || browser === 'mobile-chrome')
|
|
160
|
-
? { channel: p.channel }
|
|
161
|
-
: {}),
|
|
228
|
+
...browserUse(browser, p.channel),
|
|
162
229
|
baseURL: cfg.env.ui.baseUrl,
|
|
163
230
|
testIdAttribute: p.testIdAttribute,
|
|
164
231
|
viewport: browser.startsWith('mobile') ? undefined : p.screenshots.viewport,
|
|
165
232
|
...envUse,
|
|
233
|
+
...evidenceUse,
|
|
166
234
|
sdods: { project: p.slug, layer, browser },
|
|
167
235
|
},
|
|
168
236
|
});
|
|
@@ -227,13 +295,16 @@ export function buildRunnerConfig(registry, sel = {}) {
|
|
|
227
295
|
expect: { timeout: timeouts.expect },
|
|
228
296
|
retries,
|
|
229
297
|
workers,
|
|
230
|
-
|
|
298
|
+
// Each generated project sets its own; this is the fallback for anything added by hand.
|
|
299
|
+
fullyParallel: first
|
|
300
|
+
? runSettings(registry, first.project.slug, first.project, sel.process).fullyParallel
|
|
301
|
+
: true,
|
|
231
302
|
outputDir: join(runDir, runFiles.output),
|
|
232
303
|
reporter,
|
|
233
304
|
use: {
|
|
234
|
-
screenshot:
|
|
235
|
-
video:
|
|
236
|
-
trace:
|
|
305
|
+
screenshot: first?.project.evidence.screenshot ?? EVIDENCE_DEFAULTS.screenshot,
|
|
306
|
+
video: first?.project.evidence.video ?? EVIDENCE_DEFAULTS.video,
|
|
307
|
+
trace: first?.project.evidence.trace ?? EVIDENCE_DEFAULTS.trace,
|
|
237
308
|
actionTimeout: timeouts.action,
|
|
238
309
|
navigationTimeout: timeouts.navigation,
|
|
239
310
|
// Playwright's own `--headed` flag still wins on the CLI; this is what makes the documented
|
|
@@ -248,6 +319,66 @@ export function buildRunnerConfig(registry, sel = {}) {
|
|
|
248
319
|
},
|
|
249
320
|
};
|
|
250
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* `retries.byTag` as runner projects.
|
|
324
|
+
*
|
|
325
|
+
* Retries are fixed when a test is collected: Playwright takes them from the enclosing
|
|
326
|
+
* `test.describe.configure` or the project, and offers no runtime setter; bddgen has no hook to
|
|
327
|
+
* add `@retries:N` to a scenario it did not read from the feature file. What Playwright does
|
|
328
|
+
* offer is project-level `grep`, matched against the title AND the tags. So each byTag entry
|
|
329
|
+
* becomes a sibling of the project — same name, so `--project <name>`, reports and dashboards are
|
|
330
|
+
* unchanged — that greps for the tag and carries its retries, and the base project inverts every
|
|
331
|
+
* byTag grep. Entries are ordered by retries, highest first, and each sibling also inverts the
|
|
332
|
+
* ones before it, so a scenario with two such tags runs exactly once, with the larger count.
|
|
333
|
+
*
|
|
334
|
+
* Explicit `--retries` on the CLI still overrides every project, these included. A scenario's own
|
|
335
|
+
* `@retries:N` tag (playwright-bdd) wins over all of it.
|
|
336
|
+
*/
|
|
337
|
+
export function withRetriesByTag(project, byTag) {
|
|
338
|
+
const entries = Object.entries(byTag)
|
|
339
|
+
.map(([tag, retries]) => ({ tag: tag.startsWith('@') ? tag : `@${tag}`, retries }))
|
|
340
|
+
.sort((a, b) => b.retries - a.retries);
|
|
341
|
+
if (!entries.length)
|
|
342
|
+
return [project];
|
|
343
|
+
// Whitespace-delimited: Playwright joins the title path and tags with spaces, so `@flaky`
|
|
344
|
+
// must not match `@flakyish`. (A scenario TITLE containing the literal tag also matches.)
|
|
345
|
+
const tagRe = (tag) => new RegExp(`(^|\\s)${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`);
|
|
346
|
+
const seen = [];
|
|
347
|
+
const out = [];
|
|
348
|
+
for (const { tag, retries } of entries) {
|
|
349
|
+
const re = tagRe(tag);
|
|
350
|
+
out.push({
|
|
351
|
+
...project,
|
|
352
|
+
grep: re,
|
|
353
|
+
...(seen.length ? { grepInvert: [...seen] } : {}),
|
|
354
|
+
retries,
|
|
355
|
+
});
|
|
356
|
+
seen.push(re);
|
|
357
|
+
}
|
|
358
|
+
return [{ ...project, grepInvert: seen }, ...out];
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* `fullyParallel` and the setup tier for one project, with a named process taking precedence.
|
|
362
|
+
* A process that is not defined for this project (a multi-project run) leaves the project's own.
|
|
363
|
+
*/
|
|
364
|
+
function runSettings(registry, slug, project, processName) {
|
|
365
|
+
const proc = processName
|
|
366
|
+
? registry.processesOf(slug).find((x) => x.name === processName)
|
|
367
|
+
: undefined;
|
|
368
|
+
return {
|
|
369
|
+
fullyParallel: proc?.fullyParallel ?? project.fullyParallel,
|
|
370
|
+
setup: setupTierOf(project, proc),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The setup tier a run uses: the process's `setup` (`false` turns it off), else the project's.
|
|
375
|
+
* Tags are normalised like `--tags`, so `setup: { tags: setup }` means `@setup`.
|
|
376
|
+
*/
|
|
377
|
+
export function setupTierOf(project, proc) {
|
|
378
|
+
const setup = proc?.setup === false ? undefined : (proc?.setup ?? project.setup);
|
|
379
|
+
const tags = normalizeTagExpr(setup?.tags);
|
|
380
|
+
return tags ? { tags } : undefined;
|
|
381
|
+
}
|
|
251
382
|
function parseEnvOverrides() {
|
|
252
383
|
const raw = process.env.SDODS_CLI_OVERRIDES;
|
|
253
384
|
if (!raw)
|
package/dist/config/tags.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ProjectConfig } from '@sdods/contracts';
|
|
2
2
|
export declare const LAYER_TAGS: readonly ["@ui", "@api", "@hybrid"];
|
|
3
3
|
/** Control tags the runner interprets itself; lint passes them through untouched. */
|
|
4
4
|
export declare const RUNNER_SPECIAL_TAGS: RegExp;
|
|
@@ -6,7 +6,11 @@ export declare const RUNNER_SPECIAL_TAGS: RegExp;
|
|
|
6
6
|
export declare const PLAYWRIGHT_BDD_SPECIAL: RegExp;
|
|
7
7
|
export declare const VALUE_TAG: RegExp;
|
|
8
8
|
export declare const KNOWN_VALUE_TAGS: readonly ["env", "user", "data", "har", "jira", "github", "skip", "title", "flag"];
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Values `@skip:<browser>` accepts. Derived from the schema rather than copied, so adding a browser
|
|
11
|
+
* to `BrowserSchema` is the only edit needed — and the lint message lists the new name for free.
|
|
12
|
+
*/
|
|
13
|
+
export declare const BROWSERS_FOR_SKIP: ("chromium" | "edge" | "firefox" | "webkit" | "mobile-chrome" | "mobile-safari")[];
|
|
10
14
|
export interface TagTaxonomy {
|
|
11
15
|
layers: readonly string[];
|
|
12
16
|
suites: string[];
|
|
@@ -24,6 +28,14 @@ export declare function suiteOfTags(tags: readonly string[], suites: readonly st
|
|
|
24
28
|
export declare function combineTagExpr(layerTag: string, userExpr?: string): string;
|
|
25
29
|
/** `--tags @smoke` shorthand also accepts comma lists ("@smoke,@sanity" → "@smoke or @sanity"). */
|
|
26
30
|
export declare function normalizeTagExpr(input?: string): string | undefined;
|
|
31
|
+
export interface TagExpr {
|
|
32
|
+
evaluate(tags: readonly string[]): boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse a Cucumber tag expression, or fail as a configuration error (exit 2) that quotes it.
|
|
36
|
+
* Every command that selects scenarios by tag goes through here so they agree on what matches.
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseTagExpr(expr: string): TagExpr;
|
|
27
39
|
export interface TagGateContext {
|
|
28
40
|
/** `config.env.name` — the environment this run is actually pointed at. */
|
|
29
41
|
env: string;
|
package/dist/config/tags.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import parseInfix from 'cucumber-tag-expressions';
|
|
2
|
+
import { BrowserSchema } from '@sdods/contracts';
|
|
3
|
+
import { SdodsError } from '../errors.js';
|
|
1
4
|
export const LAYER_TAGS = ['@ui', '@api', '@hybrid'];
|
|
2
5
|
/** Control tags the runner interprets itself; lint passes them through untouched. */
|
|
3
6
|
export const RUNNER_SPECIAL_TAGS = /^@(only|skip|fixme|fail|slow|timeout:\d+|retries:\d+|mode:(parallel|serial|default))$/;
|
|
@@ -15,13 +18,11 @@ export const KNOWN_VALUE_TAGS = [
|
|
|
15
18
|
'title',
|
|
16
19
|
'flag',
|
|
17
20
|
];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
'mobile-safari',
|
|
24
|
-
];
|
|
21
|
+
/**
|
|
22
|
+
* Values `@skip:<browser>` accepts. Derived from the schema rather than copied, so adding a browser
|
|
23
|
+
* to `BrowserSchema` is the only edit needed — and the lint message lists the new name for free.
|
|
24
|
+
*/
|
|
25
|
+
export const BROWSERS_FOR_SKIP = BrowserSchema.options;
|
|
25
26
|
export function taxonomyFromProject(project) {
|
|
26
27
|
return {
|
|
27
28
|
layers: LAYER_TAGS,
|
|
@@ -75,6 +76,25 @@ export function normalizeTagExpr(input) {
|
|
|
75
76
|
.map((t) => (t.startsWith('@') ? t : `@${t}`));
|
|
76
77
|
return parts.length > 1 ? parts.join(' or ') : parts[0];
|
|
77
78
|
}
|
|
79
|
+
// The package is CommonJS with `exports.default`; ESM interop may hand back the module object.
|
|
80
|
+
const parseInfixFn = (parseInfix.default ??
|
|
81
|
+
parseInfix);
|
|
82
|
+
/**
|
|
83
|
+
* Parse a Cucumber tag expression, or fail as a configuration error (exit 2) that quotes it.
|
|
84
|
+
* Every command that selects scenarios by tag goes through here so they agree on what matches.
|
|
85
|
+
*/
|
|
86
|
+
export function parseTagExpr(expr) {
|
|
87
|
+
try {
|
|
88
|
+
const node = parseInfixFn(expr);
|
|
89
|
+
return { evaluate: (tags) => node.evaluate([...tags]) };
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
throw new SdodsError('CONFIG_INVALID', `Invalid tag expression "${expr}": ${e.message}`, {
|
|
93
|
+
hint: 'Combine tags with and / or / not and balanced parentheses, e.g. "@ui and (@smoke or @sanity)".',
|
|
94
|
+
exitCode: 2,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
78
98
|
/**
|
|
79
99
|
* Why this scenario should not run here, or undefined to run it.
|
|
80
100
|
*
|
|
@@ -30,6 +30,13 @@ export declare class ApiContext {
|
|
|
30
30
|
name: string;
|
|
31
31
|
value: string;
|
|
32
32
|
} | null | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Send through a request context with an empty cookie jar instead of the shared `request`
|
|
35
|
+
* fixture. On @ui/@hybrid the shared context starts from the leased role's storageState, so a
|
|
36
|
+
* "credential X alone is refused" assertion would otherwise pass on the session cookie.
|
|
37
|
+
* Set by `I use an isolated API client`.
|
|
38
|
+
*/
|
|
39
|
+
isolated: boolean;
|
|
33
40
|
/** step index → number of calls made during that step (for attachment numbering) */
|
|
34
41
|
readonly callsByStep: Map<number, number>;
|
|
35
42
|
get lastResponse(): ApiSnapshot['response'] | undefined;
|
|
@@ -18,6 +18,13 @@ export class ApiContext {
|
|
|
18
18
|
* refused. Any suite whose env declared `api.auth` had a no-op step.
|
|
19
19
|
*/
|
|
20
20
|
auth;
|
|
21
|
+
/**
|
|
22
|
+
* Send through a request context with an empty cookie jar instead of the shared `request`
|
|
23
|
+
* fixture. On @ui/@hybrid the shared context starts from the leased role's storageState, so a
|
|
24
|
+
* "credential X alone is refused" assertion would otherwise pass on the session cookie.
|
|
25
|
+
* Set by `I use an isolated API client`.
|
|
26
|
+
*/
|
|
27
|
+
isolated = false;
|
|
21
28
|
/** step index → number of calls made during that step (for attachment numbering) */
|
|
22
29
|
callsByStep = new Map();
|
|
23
30
|
get lastResponse() {
|
package/dist/fixtures/auth.js
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { Logger } from '../logger.js';
|
|
4
|
+
function originOf(url) {
|
|
5
|
+
try {
|
|
6
|
+
const o = new URL(url).origin;
|
|
7
|
+
return o === 'null' ? undefined : o;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Restore one origin's localStorage into a live context without navigating (#99).
|
|
15
|
+
*
|
|
16
|
+
* This used to `page.goto(origin)` and write the keys from there. An app that sends a signed-in
|
|
17
|
+
* visitor away from `/` on the client then had a redirect chain still running when the step
|
|
18
|
+
* returned, and the scenario's first `page.goto` was aborted (`net::ERR_ABORTED`). The context
|
|
19
|
+
* already exists by the time a step leases a user, so `newContext({ storageState })` is not an
|
|
20
|
+
* option here (the `@user:<role>` tag path does use it, via `ensure`).
|
|
21
|
+
*
|
|
22
|
+
* - Page already on the origin: write the keys straight into it. Nothing navigates.
|
|
23
|
+
* - Otherwise: a context init script writes them in the first document of that origin, before the
|
|
24
|
+
* app's own scripts run, in any page of the context.
|
|
25
|
+
*
|
|
26
|
+
* Planted ONCE per context, not on every navigation. An init script left in place would run again
|
|
27
|
+
* on each document, so an app that signs out (clears storage) and navigates would be silently
|
|
28
|
+
* signed back in and a logout scenario would pass while proving nothing. The script is therefore
|
|
29
|
+
* disposed once a document of that origin has reached DOMContentLoaded (by then it has certainly
|
|
30
|
+
* run). A navigation that commits in the short window before the dispose lands can run it again;
|
|
31
|
+
* it only writes keys that are absent, so it never overwrites what the app wrote meanwhile.
|
|
32
|
+
*
|
|
33
|
+
* Only localStorage is restored, as before: storageState carries no sessionStorage or IndexedDB.
|
|
34
|
+
*/
|
|
35
|
+
async function plantLocalStorage(context, page, origin, items) {
|
|
36
|
+
if (originOf(page.url()) === origin) {
|
|
37
|
+
await page.evaluate((entries) => {
|
|
38
|
+
for (const { name, value } of entries)
|
|
39
|
+
window.localStorage.setItem(name, value);
|
|
40
|
+
}, items);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const script = await context.addInitScript(({ origin: target, entries }) => {
|
|
44
|
+
if (location.origin !== target)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
for (const { name, value } of entries) {
|
|
48
|
+
if (window.localStorage.getItem(name) === null)
|
|
49
|
+
window.localStorage.setItem(name, value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// storage disabled for this document (sandboxed frame): nothing to plant into
|
|
54
|
+
}
|
|
55
|
+
}, { origin, entries: items });
|
|
56
|
+
const watched = new Set();
|
|
57
|
+
let disposed = false;
|
|
58
|
+
const onLoaded = (p) => {
|
|
59
|
+
if (disposed || originOf(p.url()) !== origin)
|
|
60
|
+
return;
|
|
61
|
+
disposed = true;
|
|
62
|
+
context.off('page', watch);
|
|
63
|
+
for (const w of watched)
|
|
64
|
+
w.off('domcontentloaded', onLoaded);
|
|
65
|
+
void script.dispose().catch(() => undefined);
|
|
66
|
+
};
|
|
67
|
+
const watch = (p) => {
|
|
68
|
+
watched.add(p);
|
|
69
|
+
p.on('domcontentloaded', onLoaded);
|
|
70
|
+
};
|
|
71
|
+
context.on('page', watch);
|
|
72
|
+
for (const p of context.pages())
|
|
73
|
+
watch(p);
|
|
74
|
+
}
|
|
4
75
|
/** How long another worker may hold the refresh lock before we treat it as crashed. */
|
|
5
76
|
const LOCK_STALE_MS = 90_000;
|
|
6
77
|
const LOCK_POLL_MS = 250;
|
|
@@ -142,11 +213,7 @@ export class AuthStateCache {
|
|
|
142
213
|
for (const origin of state.origins ?? []) {
|
|
143
214
|
if (!origin.localStorage?.length)
|
|
144
215
|
continue;
|
|
145
|
-
await page
|
|
146
|
-
await page.evaluate((items) => {
|
|
147
|
-
for (const { name, value } of items)
|
|
148
|
-
window.localStorage.setItem(name, value);
|
|
149
|
-
}, origin.localStorage);
|
|
216
|
+
await plantLocalStorage(context, page, origin.origin, origin.localStorage);
|
|
150
217
|
}
|
|
151
218
|
return true;
|
|
152
219
|
}
|
package/dist/fixtures/test.js
CHANGED
|
@@ -3,7 +3,7 @@ import { test as base, createBdd } from 'playwright-bdd';
|
|
|
3
3
|
import { ProjectRegistry } from '../config/registry.js';
|
|
4
4
|
import { parseTagValue, scenarioSkipReason } from '../config/tags.js';
|
|
5
5
|
import { noopAuth } from '../auth/index.js';
|
|
6
|
-
import { ApiClient } from '../api/client.js';
|
|
6
|
+
import { ApiClient, isolatedRequestFactory } from '../api/client.js';
|
|
7
7
|
import { CompositeDataProvider } from '../data/provider.js';
|
|
8
8
|
import { FileUserPool } from '../data/user-pool.js';
|
|
9
9
|
import { apiHarForScenario } from '../har/hooks.js';
|
|
@@ -151,9 +151,15 @@ export const test = base.extend({
|
|
|
151
151
|
apiContext: async ({}, use) => {
|
|
152
152
|
await use(new ApiContext());
|
|
153
153
|
},
|
|
154
|
-
|
|
154
|
+
// `request` is Playwright's shared APIRequestContext. It is seeded from the test's context
|
|
155
|
+
// options, so on @ui/@hybrid it carries the leased role's storageState cookies (plus any cookie
|
|
156
|
+
// a response sets); on @api storageState is undefined and it starts empty. The isolated context
|
|
157
|
+
// behind `I use an isolated API client` is only created when a scenario asks for it.
|
|
158
|
+
api: async ({ request, playwright, config, apiContext, scenario, harMode, $bddContext, $tags }, use, testInfo) => {
|
|
159
|
+
const isolated = isolatedRequestFactory(playwright.request);
|
|
155
160
|
await use(new ApiClient({
|
|
156
161
|
request,
|
|
162
|
+
isolatedRequest: isolated.get,
|
|
157
163
|
config,
|
|
158
164
|
ctx: apiContext,
|
|
159
165
|
testInfo,
|
|
@@ -161,6 +167,7 @@ export const test = base.extend({
|
|
|
161
167
|
stepIndex: () => $bddContext.stepIndex,
|
|
162
168
|
har: apiHarForScenario({ $tags, config, harMode }),
|
|
163
169
|
}));
|
|
170
|
+
await isolated.dispose();
|
|
164
171
|
},
|
|
165
172
|
data: async ({ config, scenario, apiContext }, use, testInfo) => {
|
|
166
173
|
const provider = new CompositeDataProvider(config, { seed: scenario.fingerprint });
|
package/dist/heal/healer.d.ts
CHANGED
|
@@ -46,11 +46,22 @@ export declare class Healer {
|
|
|
46
46
|
select(primary: Locator, ctx: HealContext, value: string): Promise<void>;
|
|
47
47
|
expectVisible(primary: Locator, ctx: HealContext): Promise<void>;
|
|
48
48
|
expectText(primary: Locator, ctx: HealContext, text: string | RegExp): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* The first of `locators`, in preference order, that matches anything. Waits up to
|
|
51
|
+
* primaryTimeoutMs for the LAST one to attach — callers list the loosest locator last, a
|
|
52
|
+
* superset of the others — so an element that renders a moment late is still judged by the
|
|
53
|
+
* most precise locator. When nothing matches, the most precise one is returned for resolve()
|
|
54
|
+
* to wait on and heal from.
|
|
55
|
+
*/
|
|
56
|
+
prefer(...locators: [Locator, ...Locator[]]): Promise<Locator>;
|
|
49
57
|
/**
|
|
50
58
|
* Wait briefly for the primary locator; on failure probe all candidates in parallel,
|
|
51
|
-
* score them and pick the best above minScore.
|
|
59
|
+
* score them and pick the best above minScore. A primary that is visible but matches several
|
|
60
|
+
* elements is healed too, for actions that have a role to prefer. Records a HealEvent whenever
|
|
61
|
+
* it heals.
|
|
52
62
|
*/
|
|
53
63
|
resolve(primary: Locator, ctx: HealContext, action: HealAction): Promise<Locator>;
|
|
64
|
+
private heal;
|
|
54
65
|
private probe;
|
|
55
66
|
private record;
|
|
56
67
|
}
|