@sdods/core 0.3.1 → 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.
Files changed (48) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/api/client.d.ts +62 -1
  3. package/dist/api/client.js +189 -27
  4. package/dist/api/template.d.ts +15 -2
  5. package/dist/api/template.js +31 -13
  6. package/dist/auth/capture.js +4 -3
  7. package/dist/auth/index.js +3 -18
  8. package/dist/config/defaults.js +2 -0
  9. package/dist/config/resolve.d.ts +5 -1
  10. package/dist/config/resolve.js +16 -3
  11. package/dist/config/runner.d.ts +26 -1
  12. package/dist/config/runner.js +155 -24
  13. package/dist/config/tags.d.ts +14 -2
  14. package/dist/config/tags.js +27 -7
  15. package/dist/fixtures/api-context.d.ts +7 -0
  16. package/dist/fixtures/api-context.js +7 -0
  17. package/dist/fixtures/auth.js +72 -5
  18. package/dist/fixtures/test.js +23 -6
  19. package/dist/heal/healer.d.ts +12 -1
  20. package/dist/heal/healer.js +80 -34
  21. package/dist/heal/strategies.d.ts +12 -2
  22. package/dist/heal/strategies.js +42 -0
  23. package/dist/lint/index.d.ts +1 -0
  24. package/dist/lint/index.js +44 -3
  25. package/dist/lint/steps.d.ts +13 -0
  26. package/dist/lint/steps.js +333 -0
  27. package/dist/load-module.d.ts +7 -0
  28. package/dist/load-module.js +70 -0
  29. package/dist/logger.d.ts +1 -0
  30. package/dist/logger.js +9 -3
  31. package/dist/steps/a11y.steps.js +10 -10
  32. package/dist/steps/api.steps.js +64 -27
  33. package/dist/steps/browser.steps.js +34 -33
  34. package/dist/steps/clock.steps.js +5 -5
  35. package/dist/steps/data.steps.js +61 -8
  36. package/dist/steps/db.steps.js +8 -8
  37. package/dist/steps/dom.steps.js +49 -49
  38. package/dist/steps/hybrid.steps.js +9 -5
  39. package/dist/steps/iframe.steps.js +9 -9
  40. package/dist/steps/net.steps.d.ts +12 -7
  41. package/dist/steps/net.steps.js +78 -83
  42. package/dist/steps/perf.steps.js +6 -6
  43. package/dist/steps/tabs.steps.js +6 -6
  44. package/dist/steps/ui.steps.js +94 -45
  45. package/dist/steps/webhook.steps.js +3 -3
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +15 -1
  48. package/package.json +6 -4
@@ -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
- projects.push({
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
- ...devices[DEVICE_FOR_BROWSER[browser]],
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 testDir = defineBddConfig({
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, layer))}`,
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: combineTagExpr(`@${layer}`, sel.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
- projects.push({
144
- name: runnerProjectName({ project: p.slug, layer }),
145
- testDir,
146
- use: { sdods: { project: p.slug, layer } },
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
- projects.push({
152
- name: runnerProjectName({ project: p.slug, layer, browser }),
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
- ...devices[DEVICE_FOR_BROWSER[browser]],
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
- fullyParallel: true,
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: 'off',
235
- video: 'retain-on-failure',
236
- trace: 'on-first-retry',
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)
@@ -1,4 +1,4 @@
1
- import type { ProjectConfig } from '@sdods/contracts';
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
- export declare const BROWSERS_FOR_SKIP: readonly ["chromium", "firefox", "webkit", "mobile-chrome", "mobile-safari"];
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;
@@ -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
- export const BROWSERS_FOR_SKIP = [
19
- 'chromium',
20
- 'firefox',
21
- 'webkit',
22
- 'mobile-chrome',
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() {
@@ -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.goto(origin.origin, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
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
  }
@@ -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';
@@ -90,9 +90,15 @@ export const test = base.extend({
90
90
  ],
91
91
  // ── test scope ──────────────────────────────────────────────────────────
92
92
  /**
93
- * Declared FIRST in test scope, and automatic, so it decides before anything
94
- * expensive happens — before a pool account is leased, before a browser
95
- * context is built, before a session is minted.
93
+ * Automatic, and an explicit DEPENDENCY of `user` — which is what actually makes
94
+ * it decide before anything expensive happens.
95
+ *
96
+ * Declaration order alone does not: Playwright instantiates fixtures in
97
+ * dependency order, and `user` is pulled in by `storageState`, which the browser
98
+ * context needs, so the pool lease happened first regardless of where the gate
99
+ * sat in the object. An `@env:local @user:noconsent` scenario on staging failed
100
+ * with `No users with role "noconsent"` instead of skipping — the gate never got
101
+ * to run. Depending on it is the only ordering guarantee Playwright offers.
96
102
  *
97
103
  * `@env:`, `@skip:<browser>` and `@flag:` were validated at LINT time and had
98
104
  * no runtime path whatsoever. That is the worst arrangement available: the
@@ -145,9 +151,15 @@ export const test = base.extend({
145
151
  apiContext: async ({}, use) => {
146
152
  await use(new ApiContext());
147
153
  },
148
- api: async ({ request, config, apiContext, scenario, harMode, $bddContext, $tags }, use, testInfo) => {
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);
149
160
  await use(new ApiClient({
150
161
  request,
162
+ isolatedRequest: isolated.get,
151
163
  config,
152
164
  ctx: apiContext,
153
165
  testInfo,
@@ -155,6 +167,7 @@ export const test = base.extend({
155
167
  stepIndex: () => $bddContext.stepIndex,
156
168
  har: apiHarForScenario({ $tags, config, harMode }),
157
169
  }));
170
+ await isolated.dispose();
158
171
  },
159
172
  data: async ({ config, scenario, apiContext }, use, testInfo) => {
160
173
  const provider = new CompositeDataProvider(config, { seed: scenario.fingerprint });
@@ -171,7 +184,11 @@ export const test = base.extend({
171
184
  }
172
185
  void apiContext;
173
186
  },
174
- user: async ({ userPool, $tags }, use, testInfo) => {
187
+ // `$sdodsTagGate` first, and it is not unused: it is here to force the ordering.
188
+ // Leasing an account for a scenario that the tag gate is about to skip burns a
189
+ // pool slot, and on a role with no rows it throws before the skip can happen.
190
+ user: async ({ $sdodsTagGate, userPool, $tags }, use, testInfo) => {
191
+ void $sdodsTagGate;
175
192
  const role = parseTagValue($tags, 'user');
176
193
  await use(role ? await userPool.lease(role, testInfo.parallelIndex) : undefined);
177
194
  },
@@ -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. Records a HealEvent either way.
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
  }