@sdods/core 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/a11y/audit.d.ts +88 -0
  3. package/dist/a11y/audit.js +134 -0
  4. package/dist/api/template.js +1 -1
  5. package/dist/config/defaults.js +2 -0
  6. package/dist/config/resolve.js +1 -1
  7. package/dist/data/index.d.ts +1 -0
  8. package/dist/data/index.js +1 -0
  9. package/dist/data/pool-capacity.d.ts +58 -0
  10. package/dist/data/pool-capacity.js +138 -0
  11. package/dist/data/user-pool.d.ts +43 -2
  12. package/dist/data/user-pool.js +170 -40
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/fixtures/auth.js +1 -1
  15. package/dist/fixtures/scenario.d.ts +11 -0
  16. package/dist/fixtures/scenario.js +15 -0
  17. package/dist/fixtures/test.js +24 -3
  18. package/dist/fixtures/types.d.ts +2 -0
  19. package/dist/har/api-har.js +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/matrix/index.d.ts +13 -0
  23. package/dist/matrix/index.js +25 -4
  24. package/dist/perf/vitals.d.ts +118 -0
  25. package/dist/perf/vitals.js +236 -0
  26. package/dist/quality/a11y-scenario.d.ts +110 -0
  27. package/dist/quality/a11y-scenario.js +229 -0
  28. package/dist/quality/gates.d.ts +87 -0
  29. package/dist/quality/gates.js +233 -0
  30. package/dist/quality/hooks.d.ts +2 -0
  31. package/dist/quality/hooks.js +51 -0
  32. package/dist/quality/index.d.ts +4 -0
  33. package/dist/quality/index.js +2 -0
  34. package/dist/quality/perf-scenario.d.ts +149 -0
  35. package/dist/quality/perf-scenario.js +265 -0
  36. package/dist/shots/baselines.d.ts +72 -0
  37. package/dist/shots/baselines.js +201 -0
  38. package/dist/shots/hooks.js +2 -2
  39. package/dist/shots/index.d.ts +1 -0
  40. package/dist/shots/index.js +1 -0
  41. package/dist/shots/narrator.d.ts +10 -2
  42. package/dist/shots/narrator.js +106 -10
  43. package/dist/shots/policy.d.ts +5 -1
  44. package/dist/shots/policy.js +2 -0
  45. package/dist/steps/a11y.steps.d.ts +18 -51
  46. package/dist/steps/a11y.steps.js +6 -107
  47. package/dist/steps/data.steps.js +17 -5
  48. package/dist/steps/hooks.steps.d.ts +1 -0
  49. package/dist/steps/hooks.steps.js +3 -1
  50. package/dist/steps/index.d.ts +1 -0
  51. package/dist/steps/index.js +1 -0
  52. package/dist/steps/perf.steps.d.ts +3 -129
  53. package/dist/steps/perf.steps.js +4 -227
  54. package/dist/steps/ui.steps.js +5 -0
  55. package/package.json +7 -7
@@ -0,0 +1,88 @@
1
+ import type { Page } from '@playwright/test';
2
+ /**
3
+ * The axe audit itself, shared by the explicit accessibility steps (`steps/a11y.steps.ts`) and the
4
+ * end-of-scenario audit every `@a11y` scenario gets (`quality/hooks.ts`).
5
+ *
6
+ * It lives outside the step file on purpose. A step file registers its phrases when it is imported,
7
+ * and a project can switch a whole core library off with `steps.core.exclude: [a11y]` because its
8
+ * own steps already use those phrases. If the tag hook imported the step file, it would register
9
+ * those phrases again behind the project's back and bring back the collision it had excluded.
10
+ */
11
+ /**
12
+ * Structural subset of axe-core's result types. Declared locally rather than imported from
13
+ * `axe-core`: that package is a transitive dependency of `@axe-core/playwright`, not a direct
14
+ * dependency of this one, so importing its types would couple the whole step library's
15
+ * compilation to a package a consumer may not have installed.
16
+ */
17
+ export interface A11yViolationNode {
18
+ target: unknown[];
19
+ html?: string;
20
+ failureSummary?: string;
21
+ }
22
+ export interface A11yViolation {
23
+ id: string;
24
+ impact?: string | null;
25
+ help?: string;
26
+ helpUrl?: string;
27
+ nodes: A11yViolationNode[];
28
+ }
29
+ export interface A11yResults {
30
+ violations: A11yViolation[];
31
+ passes: {
32
+ id: string;
33
+ }[];
34
+ incomplete: {
35
+ id: string;
36
+ }[];
37
+ inapplicable: {
38
+ id: string;
39
+ }[];
40
+ url?: string;
41
+ }
42
+ /** axe impact levels, weakest first. `impact: null` is treated as `minor`. */
43
+ export declare const IMPACT_ORDER: readonly ["minor", "moderate", "serious", "critical"];
44
+ /**
45
+ * WCAG 2.0/2.1/2.2 level A and AA, and deliberately nothing else. axe's `best-practice` tag set
46
+ * changes between axe releases, so including it would let the same unchanged page pass one week
47
+ * and fail the next — a step whose verdict moves on its own teaches teams to ignore it.
48
+ */
49
+ export declare const WCAG_AA_TAGS: string[];
50
+ /** Rank of an axe impact level; an absent impact ranks as `minor` rather than vanishing. */
51
+ export declare function impactRank(impact: string | null | undefined): number;
52
+ /** Rejects an impact level the feature file invented, instead of silently matching nothing. */
53
+ export declare function parseImpactFloor(level: string): number;
54
+ export declare function violationsAtOrAbove(violations: A11yViolation[], floor: number): A11yViolation[];
55
+ /** Human-readable violation list: rule, impact, help text and the first few offending nodes. */
56
+ export declare function describeViolations(violations: A11yViolation[]): string;
57
+ /**
58
+ * PROVES the audit actually executed. axe reporting zero violations is indistinguishable from
59
+ * axe never having run: a nonce'd CSP that blocks the injected source, an `about:blank` page, or
60
+ * an `include` that matched nothing all produce an empty, green-looking result. If no rule landed
61
+ * in passes, violations or incomplete, then nothing was examined and the verdict means nothing.
62
+ */
63
+ export declare function assertAxeChecked(results: A11yResults, where: string): void;
64
+ export interface AxeRunOptions {
65
+ include?: string | string[];
66
+ exclude?: string[];
67
+ tags?: string[];
68
+ rules?: string[];
69
+ }
70
+ /**
71
+ * Runs axe through `@axe-core/playwright`, which injects the source with `page.evaluate` rather
72
+ * than `addScriptTag`. That matters: an app serving a nonce'd CSP blocks an injected `<script>`
73
+ * silently, and the audit would report zero violations because it never ran.
74
+ *
75
+ * The import is lazy so a project that never audits does not need the dependency — a top-level
76
+ * import would fail the load of every step file that reaches this module.
77
+ */
78
+ export declare function runAxe(page: Page, opts: AxeRunOptions): Promise<A11yResults>;
79
+ /** The file writer and attacher both audits use; the name and path are the caller's. */
80
+ export declare function writeAndAttachJson(testInfo: {
81
+ attach(name: string, opts: {
82
+ path: string;
83
+ contentType: string;
84
+ }): Promise<void>;
85
+ }, name: string, file: string, body: unknown): Promise<void>;
86
+ export declare function markPageAudited(scenario: object, url: string): void;
87
+ export declare function wasPageAudited(scenario: object, url: string): boolean;
88
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1,134 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { SdodsError } from '../errors.js';
3
+ /** axe impact levels, weakest first. `impact: null` is treated as `minor`. */
4
+ export const IMPACT_ORDER = ['minor', 'moderate', 'serious', 'critical'];
5
+ /**
6
+ * WCAG 2.0/2.1/2.2 level A and AA, and deliberately nothing else. axe's `best-practice` tag set
7
+ * changes between axe releases, so including it would let the same unchanged page pass one week
8
+ * and fail the next — a step whose verdict moves on its own teaches teams to ignore it.
9
+ */
10
+ export const WCAG_AA_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
11
+ /* ── pure judges ───────────────────────────────────────────────────────── */
12
+ /** Rank of an axe impact level; an absent impact ranks as `minor` rather than vanishing. */
13
+ export function impactRank(impact) {
14
+ const i = IMPACT_ORDER.indexOf((impact ?? 'minor'));
15
+ return i === -1 ? 0 : i;
16
+ }
17
+ /** Rejects an impact level the feature file invented, instead of silently matching nothing. */
18
+ export function parseImpactFloor(level) {
19
+ const i = IMPACT_ORDER.indexOf(level);
20
+ if (i === -1)
21
+ throw new SdodsError('CONFIG_INVALID', `"${level}" is not an axe impact level.`, {
22
+ hint: `Use one of: ${IMPACT_ORDER.join(', ')}.`,
23
+ });
24
+ return i;
25
+ }
26
+ export function violationsAtOrAbove(violations, floor) {
27
+ return violations.filter((v) => impactRank(v.impact) >= floor);
28
+ }
29
+ /** Human-readable violation list: rule, impact, help text and the first few offending nodes. */
30
+ export function describeViolations(violations) {
31
+ return violations
32
+ .map((v) => {
33
+ const nodes = v.nodes ?? [];
34
+ const shown = nodes
35
+ .slice(0, 3)
36
+ .map((n) => (Array.isArray(n.target) ? n.target.join(' ') : String(n.target)))
37
+ .join(' | ');
38
+ const more = nodes.length > 3 ? ` (+${nodes.length - 3} more node(s))` : '';
39
+ return `${v.id} [${v.impact ?? 'minor'}] ${v.help ?? ''} → ${nodes.length} node(s): ${shown}${more}`;
40
+ })
41
+ .join('\n');
42
+ }
43
+ /**
44
+ * PROVES the audit actually executed. axe reporting zero violations is indistinguishable from
45
+ * axe never having run: a nonce'd CSP that blocks the injected source, an `about:blank` page, or
46
+ * an `include` that matched nothing all produce an empty, green-looking result. If no rule landed
47
+ * in passes, violations or incomplete, then nothing was examined and the verdict means nothing.
48
+ */
49
+ export function assertAxeChecked(results, where) {
50
+ const checked = (results.passes?.length ?? 0) +
51
+ (results.violations?.length ?? 0) +
52
+ (results.incomplete?.length ?? 0);
53
+ if (checked === 0)
54
+ throw new SdodsError('RUN_FAILED', `axe examined nothing ${where}; the audit proves nothing.`, {
55
+ hint: 'The page may be blank, the scoped selector may match an empty element, or a Content-Security-Policy may have blocked axe from running. Open the attached sdods/a11y JSON to see what axe reported.',
56
+ });
57
+ }
58
+ /**
59
+ * Runs axe through `@axe-core/playwright`, which injects the source with `page.evaluate` rather
60
+ * than `addScriptTag`. That matters: an app serving a nonce'd CSP blocks an injected `<script>`
61
+ * silently, and the audit would report zero violations because it never ran.
62
+ *
63
+ * The import is lazy so a project that never audits does not need the dependency — a top-level
64
+ * import would fail the load of every step file that reaches this module.
65
+ */
66
+ export async function runAxe(page, opts) {
67
+ let AxeBuilder;
68
+ try {
69
+ const mod = await import('@axe-core/playwright');
70
+ // Named export first: the package exports the class as both `AxeBuilder` and `default`, and a
71
+ // CJS interop path can hand `default` back as the namespace object rather than the class.
72
+ AxeBuilder = (mod.AxeBuilder ?? mod.default);
73
+ }
74
+ catch (cause) {
75
+ throw new SdodsError('NOT_SUPPORTED', 'Accessibility audits need @axe-core/playwright.', {
76
+ hint: 'Install it in the project that runs these steps: `npm i -D @axe-core/playwright`.',
77
+ cause,
78
+ });
79
+ }
80
+ const include = typeof opts.include === 'string' ? [opts.include] : (opts.include ?? []);
81
+ const builder = new AxeBuilder({ page });
82
+ for (const sel of include)
83
+ builder.include(sel);
84
+ for (const sel of opts.exclude ?? [])
85
+ builder.exclude(sel);
86
+ // withTags and withRules are mutually exclusive in axe-core; never set both.
87
+ if (opts.rules)
88
+ builder.withRules(opts.rules);
89
+ else
90
+ builder.withTags(opts.tags ?? WCAG_AA_TAGS);
91
+ try {
92
+ return await builder.analyze();
93
+ }
94
+ catch (cause) {
95
+ // axe raises "unknown rule `x` in options.runOnly" for a rule id that does not exist, and
96
+ // "No elements found for include in page Context" for a selector that matched nothing. Both
97
+ // are defects in the step rather than in the page under test, so both are re-raised as such.
98
+ const message = cause instanceof Error ? cause.message : String(cause);
99
+ if (/unknown rule/i.test(message))
100
+ throw new SdodsError('CONFIG_INVALID', `axe rejected the rule id: ${message}`, {
101
+ hint: 'Check the rule id against https://dequeuniversity.com/rules/axe.',
102
+ cause,
103
+ });
104
+ if (/no elements found for include/i.test(message))
105
+ throw new SdodsError('RUN_FAILED', `axe found nothing to scan: ${message}`, {
106
+ hint: `The selector "${include.join(', ')}" matched no element by the time axe ran. Wait for the region to render first, or fix the selector.`,
107
+ cause,
108
+ });
109
+ throw new SdodsError('RUN_FAILED', `axe failed to analyse the page: ${message}`, { cause });
110
+ }
111
+ }
112
+ /** The file writer and attacher both audits use; the name and path are the caller's. */
113
+ export async function writeAndAttachJson(testInfo, name, file, body) {
114
+ writeFileSync(file, JSON.stringify(body, null, 2));
115
+ await testInfo.attach(name, { path: file, contentType: 'application/json' });
116
+ }
117
+ /* ── which URLs a scenario has already audited ─────────────────────────── */
118
+ /**
119
+ * Whole-page audits an explicit step already ran in this scenario, by URL. The `@a11y` tag audit
120
+ * skips a URL listed here: the author wrote the step, chose its threshold, and it has already passed
121
+ * or failed. Scoped (`within …`) and single-rule audits are not recorded, because they did not look
122
+ * at the whole page. Keyed on the per-test `scenario` object, so nothing leaks between tests.
123
+ */
124
+ const audited = new WeakMap();
125
+ export function markPageAudited(scenario, url) {
126
+ let set = audited.get(scenario);
127
+ if (!set)
128
+ audited.set(scenario, (set = new Set()));
129
+ set.add(url);
130
+ }
131
+ export function wasPageAudited(scenario, url) {
132
+ return audited.get(scenario)?.has(url) ?? false;
133
+ }
134
+ //# sourceMappingURL=audit.js.map
@@ -59,7 +59,7 @@ export function renderJson(template, ...scopes) {
59
59
  return JSON.parse(text);
60
60
  }
61
61
  catch (e) {
62
- throw new Error(`Body is not valid JSON after rendering templates: ${e.message}\n${text}`);
62
+ throw new Error(`Body is not valid JSON after rendering templates: ${e.message}\n${text}`, { cause: e });
63
63
  }
64
64
  }
65
65
  //# sourceMappingURL=template.js.map
@@ -11,6 +11,8 @@ export const FRAMEWORK_DEFAULTS = {
11
11
  },
12
12
  fullPage: false,
13
13
  mask: [],
14
+ maxDiffPixelRatio: 0.01,
15
+ baselines: {},
14
16
  viewport: { width: 1280, height: 720 },
15
17
  onlyOnFailure: false,
16
18
  },
@@ -93,7 +93,7 @@ export function resolveConfig(opts) {
93
93
  markAll(envYaml, prov, 'envYaml', 'env');
94
94
  // env-level overrides of project sections
95
95
  let projectMerged = project;
96
- for (const section of ['screenshots', 'heal', 'timeouts', 'perf', 'evidence']) {
96
+ for (const section of ['screenshots', 'heal', 'timeouts', 'perf', 'a11y', 'evidence']) {
97
97
  const patch = envYaml[section];
98
98
  if (patch) {
99
99
  projectMerged = {
@@ -4,4 +4,5 @@ export * from './loaders.js';
4
4
  export * from './provider.js';
5
5
  export * from './user-pool.js';
6
6
  export * from './factories.js';
7
+ export * from './pool-capacity.js';
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,5 @@ export * from './loaders.js';
4
4
  export * from './provider.js';
5
5
  export * from './user-pool.js';
6
6
  export * from './factories.js';
7
+ export * from './pool-capacity.js';
7
8
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,58 @@
1
+ import type { BrowserName, Layer, UserPoolConfig } from '@sdods/contracts';
2
+ import type { ResolvedConfig } from '../config/resolve.js';
3
+ import { SdodsError } from '../errors.js';
4
+ export interface PoolDemandOptions {
5
+ /** Selected layers; `recorded` has no Gherkin and takes no `@user:` lease. */
6
+ layers: readonly Layer[];
7
+ /** Selected browsers (each is its own run target for ui/hybrid). */
8
+ browsers: readonly BrowserName[];
9
+ /** `--tags`, already normalised. */
10
+ tags?: string;
11
+ /** The setup tier's tags: those scenarios run before the targets they gate, not beside them. */
12
+ setupTags?: string;
13
+ /** Feature flags the env declares (`env.vars.features`); undefined gates nothing. */
14
+ flags?: readonly string[];
15
+ quarantine?: 'run' | 'skip';
16
+ fullyParallel: boolean;
17
+ /** Keep a feature, given its path relative to the project root. */
18
+ includeFeature?: (projectRelativePath: string) => boolean;
19
+ /** Keep a scenario by name (`--scenario` / `--grep`, matched on the scenario name only). */
20
+ includeScenario?: (name: string) => boolean;
21
+ repeatEach?: number;
22
+ shardTotal?: number;
23
+ }
24
+ /**
25
+ * For each `@user:<role>`, how many units of work that lease it could run at once: one per
26
+ * scenario per run target, or one per feature file per target where the file runs serially
27
+ * (`fullyParallel: false`, `@mode:serial`, `@mode:default`).
28
+ */
29
+ export declare function poolDemandByRole(project: {
30
+ root: string;
31
+ }, env: string, o: PoolDemandOptions): Map<string, number>;
32
+ export interface PoolShortfall {
33
+ role: string;
34
+ /** Accounts with this role after `env.users.poolSize`. */
35
+ accounts: number;
36
+ /** Scenarios (or serial files) with this role that the workers could run at once. */
37
+ concurrent: number;
38
+ }
39
+ /**
40
+ * Roles whose exclusive, worker-held leases cannot cover the workers that will ask for them.
41
+ *
42
+ * Empty whenever contention cannot starve a worker: `mode: shared` takes no lease,
43
+ * `leaseScope: scenario` queues instead of starving, and a dataset that is not file-backed has no
44
+ * count to compare. A role with no account at all is not a capacity problem — it fails at any
45
+ * worker count, and the lease names the roles that do exist.
46
+ */
47
+ export declare function checkPoolCapacity(opts: {
48
+ pool: UserPoolConfig | undefined;
49
+ accounts: ReadonlyMap<string, number> | undefined;
50
+ demand: ReadonlyMap<string, number>;
51
+ workers: number;
52
+ }): PoolShortfall[];
53
+ /** Accounts per role for the resolved env, after `env.users.poolSize`. Undefined if not file-backed. */
54
+ export declare function poolAccountsFor(config: ResolvedConfig): Map<string, number> | undefined;
55
+ /** How the worker count was arrived at, for messages. */
56
+ export declare function describeWorkers(workers: number | undefined): string;
57
+ export declare function poolTooSmallError(config: ResolvedConfig, shortfalls: readonly PoolShortfall[], runtimeWorkers: number | undefined): SdodsError;
58
+ //# sourceMappingURL=pool-capacity.d.ts.map
@@ -0,0 +1,138 @@
1
+ import { cpus } from 'node:os';
2
+ import { relative } from 'node:path';
3
+ import { combineTagExpr, parseTagExpr, parseTagValue, scenarioSkipReason } from '../config/tags.js';
4
+ import { SdodsError } from '../errors.js';
5
+ import { parseFeatureFile } from '../lint/gherkin.js';
6
+ import { listFeatureFiles } from '../lint/index.js';
7
+ import { poolAccountsByEnv } from '../matrix/index.js';
8
+ import { effectiveWorkers, leaseWaitMs } from './user-pool.js';
9
+ /**
10
+ * #153: an exclusive lease is held for a worker's whole life, so a role with fewer accounts than
11
+ * the workers that reach its scenarios starves every other worker. Each one waits `waitMs` and
12
+ * fails the scenario with USER_POOL_EXHAUSTED, once per scenario, 30s into it. Measured on a real
13
+ * suite: 55 passed / 335 failed at --workers 4 with one `member` account. This decides that before
14
+ * any spec is generated.
15
+ *
16
+ * It must never cry wolf, so every estimate below errs towards FEWER concurrent leases: a scenario
17
+ * the tag gate would skip, a filter only approximated here, or a shard's share all count low.
18
+ */
19
+ /** Playwright's `browserName` for an SDODS browser: what the runtime tag gate compares `@skip:` with. */
20
+ const ENGINE = {
21
+ chromium: 'chromium',
22
+ edge: 'chromium',
23
+ firefox: 'firefox',
24
+ webkit: 'webkit',
25
+ 'mobile-chrome': 'chromium',
26
+ 'mobile-safari': 'webkit',
27
+ };
28
+ /**
29
+ * For each `@user:<role>`, how many units of work that lease it could run at once: one per
30
+ * scenario per run target, or one per feature file per target where the file runs serially
31
+ * (`fullyParallel: false`, `@mode:serial`, `@mode:default`).
32
+ */
33
+ export function poolDemandByRole(project, env, o) {
34
+ const setup = o.setupTags ? parseTagExpr(o.setupTags) : undefined;
35
+ const layers = o.layers
36
+ .filter((l) => l !== 'recorded')
37
+ .map((layer) => ({ layer, expr: parseTagExpr(combineTagExpr(`@${layer}`, o.tags)) }));
38
+ const units = new Map();
39
+ for (const file of listFeatureFiles(project.root)) {
40
+ const rel = relative(project.root, file).replace(/\\/g, '/');
41
+ if (o.includeFeature && !o.includeFeature(rel))
42
+ continue;
43
+ const parsed = parseFeatureFile(file);
44
+ for (const pickle of parsed.pickles) {
45
+ const tags = pickle.tags.map((t) => t.name);
46
+ const role = parseTagValue(tags, 'user');
47
+ if (!role || setup?.evaluate(tags))
48
+ continue;
49
+ if (o.includeScenario && !o.includeScenario(pickle.name))
50
+ continue;
51
+ const serial = tags.includes('@mode:serial') ||
52
+ tags.includes('@mode:default') ||
53
+ (!o.fullyParallel && !tags.includes('@mode:parallel'));
54
+ for (const { layer, expr } of layers) {
55
+ if (!expr.evaluate(tags))
56
+ continue;
57
+ const targets = layer === 'api' ? [undefined] : o.browsers;
58
+ for (const browser of targets) {
59
+ const gate = { env, flags: o.flags, quarantine: o.quarantine };
60
+ // The runtime gate sees Playwright's engine name; lint accepts the SDODS name. Skipped
61
+ // under either reading counts as skipped, which can only lower the estimate.
62
+ if (scenarioSkipReason(tags, gate) ||
63
+ (browser &&
64
+ (scenarioSkipReason(tags, { ...gate, browser }) ||
65
+ scenarioSkipReason(tags, { ...gate, browser: ENGINE[browser] }))))
66
+ continue;
67
+ const key = `${layer}|${browser ?? ''}|${rel}${serial ? '' : `|${pickle.id}`}`;
68
+ let set = units.get(role);
69
+ if (!set)
70
+ units.set(role, (set = new Set()));
71
+ set.add(key);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ const out = new Map();
77
+ for (const [role, set] of units) {
78
+ let n = set.size * Math.max(1, o.repeatEach ?? 1);
79
+ // A shard runs some share of the tests; the smallest guaranteed share is the honest estimate.
80
+ if (o.shardTotal && o.shardTotal > 1)
81
+ n = Math.floor(n / o.shardTotal);
82
+ out.set(role, n);
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * Roles whose exclusive, worker-held leases cannot cover the workers that will ask for them.
88
+ *
89
+ * Empty whenever contention cannot starve a worker: `mode: shared` takes no lease,
90
+ * `leaseScope: scenario` queues instead of starving, and a dataset that is not file-backed has no
91
+ * count to compare. A role with no account at all is not a capacity problem — it fails at any
92
+ * worker count, and the lease names the roles that do exist.
93
+ */
94
+ export function checkPoolCapacity(opts) {
95
+ const { pool, accounts } = opts;
96
+ if (!pool || pool.mode === 'shared' || pool.leaseScope === 'scenario' || !accounts)
97
+ return [];
98
+ const out = [];
99
+ for (const [role, units] of opts.demand) {
100
+ const have = accounts.get(role) ?? 0;
101
+ const concurrent = Math.min(opts.workers, units);
102
+ if (have > 0 && have < concurrent)
103
+ out.push({ role, accounts: have, concurrent });
104
+ }
105
+ return out.sort((a, b) => a.role.localeCompare(b.role));
106
+ }
107
+ /** Accounts per role for the resolved env, after `env.users.poolSize`. Undefined if not file-backed. */
108
+ export function poolAccountsFor(config) {
109
+ return poolAccountsByEnv(config.project, {
110
+ envs: [config.env.name],
111
+ poolSize: () => config.env.users.poolSize,
112
+ })?.get(config.env.name);
113
+ }
114
+ /** How the worker count was arrived at, for messages. */
115
+ export function describeWorkers(workers) {
116
+ return workers !== undefined
117
+ ? `${workers} worker(s)`
118
+ : `${effectiveWorkers()} worker(s) (Playwright's default: half of ${cpus().length} cores)`;
119
+ }
120
+ export function poolTooSmallError(config, shortfalls, runtimeWorkers) {
121
+ const pool = config.project.data.userPool;
122
+ const lines = shortfalls.map((s) => `role "${s.role}" has ${s.accounts} account(s) but ${s.concurrent} of its scenarios can run at once`);
123
+ return new SdodsError('USER_POOL_TOO_SMALL', `User pool too small for ${describeWorkers(runtimeWorkers)} in env ${config.env.name}: ${lines.join('; ')}. ` +
124
+ `Leases are held per worker, so every worker past the account count would wait ` +
125
+ `${pool ? Math.round(leaseWaitMs(pool) / 1000) : 30}s and fail each of those scenarios with USER_POOL_EXHAUSTED.`, {
126
+ hint: `Any one of: set data.userPool.leaseScope: scenario to release an account when each ` +
127
+ `scenario ends (scenarios then queue for it); add accounts with ` +
128
+ `${shortfalls.map((s) => `role "${s.role}"`).join(', ')} to dataset "${pool?.dataset}"` +
129
+ `${config.env.users.poolSize ? ` (env.users.poolSize ${config.env.users.poolSize} caps the dataset before roles are split)` : ''}; ` +
130
+ `set data.userPool.mode: shared if these scenarios do not mutate user-scoped state; ` +
131
+ `run with --workers ${Math.max(1, Math.min(...shortfalls.map((s) => s.accounts)))}; ` +
132
+ `or pass --allow-pool-contention to run anyway.`,
133
+ docsPath: '/docs/guides/test-data-and-user-pools',
134
+ details: { env: config.env.name, shortfalls },
135
+ exitCode: 2,
136
+ });
137
+ }
138
+ //# sourceMappingURL=pool-capacity.js.map
@@ -1,3 +1,4 @@
1
+ import type { UserPoolConfig } from '@sdods/contracts';
1
2
  import type { ResolvedConfig } from '../config/resolve.js';
2
3
  import type { DataProvider, LeaseStore, LeasedUser, UserPool } from './types.js';
3
4
  /** O_EXCL lock files with TTL; safe across workers on one machine. */
@@ -14,10 +15,37 @@ export interface UserPoolOptions {
14
15
  owner: string;
15
16
  store?: LeaseStore;
16
17
  waitMs?: number;
18
+ /** Where waiters take their place in line. Default: `.queue` beside the lease files. */
19
+ queueDir?: string;
17
20
  }
21
+ /** `data.userPool.waitMs` when unset and leases are held per worker. */
22
+ export declare const DEFAULT_LEASE_WAIT_MS = 30000;
23
+ /**
24
+ * The worker count Playwright will actually use: `--workers` / `SDODS_WORKERS` when set (both land
25
+ * in `runtime.workers`), else Playwright's own default of half the logical cores.
26
+ *
27
+ * Every browser and layer of one `sdods run` shares this pool: the CLI starts ONE `playwright test`
28
+ * with a `--project` per run target, and Playwright's `workers` caps the whole invocation.
29
+ */
30
+ export declare function effectiveWorkers(workers?: number): number;
31
+ /** How long a lease request waits for a free account (see `data.userPool.waitMs`). */
32
+ export declare function leaseWaitMs(pool: Pick<UserPoolConfig, 'waitMs' | 'leaseScope' | 'leaseTtlMs'>): number;
33
+ /**
34
+ * With `leaseScope: scenario`, waiting for an account is how one account serialises the scenarios
35
+ * that need it, so the wait must not count against the scenario's own timeout: a scenario third in
36
+ * line would otherwise fail having done nothing. The timeout is raised by the longest possible wait
37
+ * before leasing, then settled to what was actually waited.
38
+ */
39
+ export declare function scenarioLeaseClock(pool: UserPoolConfig | undefined, testInfo: {
40
+ timeout: number;
41
+ setTimeout(ms: number): void;
42
+ }): {
43
+ settle(): void;
44
+ };
18
45
  /**
19
46
  * Pool of accounts from the configured dataset, partitioned by role and capped by env.users.poolSize.
20
- * Leases are keyed by owner (`runId:shardOffset+parallelIndex`) and released on worker teardown.
47
+ * Leases are keyed by owner (`runId:shardOffset+parallelIndex`) and released on worker teardown, or
48
+ * at scenario teardown with `leaseScope: scenario`.
21
49
  */
22
50
  export declare class FileUserPool implements UserPool {
23
51
  private readonly config;
@@ -26,11 +54,24 @@ export declare class FileUserPool implements UserPool {
26
54
  private readonly log;
27
55
  private readonly leased;
28
56
  private rows?;
29
- private readonly store;
57
+ private storeInstance?;
58
+ private readonly leaseDir;
30
59
  constructor(config: ResolvedConfig, data: DataProvider, opts: UserPoolOptions);
60
+ /**
61
+ * Created on first use. Every scenario depends on the pool (the scenario-scope release fixture is
62
+ * automatic), and a project that never leases must not grow a lease directory for it.
63
+ */
64
+ private get store();
31
65
  static ownerFor(config: ResolvedConfig, parallelIndex: number): string;
32
66
  private users;
33
67
  lease(role: string, _parallelIndex: number): Promise<LeasedUser>;
68
+ /**
69
+ * A place in line for `role`: an empty file named by arrival time and owner, refreshed while its
70
+ * waiter polls. `ahead()` counts live tickets that arrived earlier, and a waiter only tries to
71
+ * acquire while fewer are ahead of it than the role has accounts. A ticket nobody refreshes (its
72
+ * worker crashed) goes stale and stops holding the line.
73
+ */
74
+ private queueTicket;
34
75
  release(user: LeasedUser): Promise<void>;
35
76
  releaseAll(): Promise<void>;
36
77
  status(): Promise<{