@sdods/core 0.7.2 → 0.9.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 (94) 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/analyze/index.d.ts +2 -0
  5. package/dist/analyze/index.js +2 -0
  6. package/dist/analyze/traceability-render.d.ts +5 -0
  7. package/dist/analyze/traceability-render.js +254 -0
  8. package/dist/analyze/traceability.d.ts +87 -0
  9. package/dist/analyze/traceability.js +559 -0
  10. package/dist/api/json-path.d.ts +11 -1
  11. package/dist/api/json-path.js +23 -1
  12. package/dist/config/defaults.js +2 -0
  13. package/dist/config/emulation.d.ts +74 -0
  14. package/dist/config/emulation.js +154 -0
  15. package/dist/config/index.d.ts +1 -0
  16. package/dist/config/index.js +1 -0
  17. package/dist/config/resolve.js +1 -1
  18. package/dist/config/runner.js +4 -1
  19. package/dist/config/tags.d.ts +1 -1
  20. package/dist/config/tags.js +7 -0
  21. package/dist/data/index.d.ts +1 -0
  22. package/dist/data/index.js +1 -0
  23. package/dist/data/pool-capacity.d.ts +58 -0
  24. package/dist/data/pool-capacity.js +138 -0
  25. package/dist/data/user-pool.d.ts +43 -2
  26. package/dist/data/user-pool.js +170 -40
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/fixtures/api-context.d.ts +19 -1
  29. package/dist/fixtures/api-context.js +28 -1
  30. package/dist/fixtures/scenario.d.ts +11 -0
  31. package/dist/fixtures/scenario.js +15 -0
  32. package/dist/fixtures/test.js +60 -3
  33. package/dist/fixtures/types.d.ts +5 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +4 -0
  36. package/dist/lint/index.js +86 -0
  37. package/dist/load/index.d.ts +4 -0
  38. package/dist/load/index.js +4 -0
  39. package/dist/load/profile.d.ts +22 -0
  40. package/dist/load/profile.js +109 -0
  41. package/dist/load/run.d.ts +85 -0
  42. package/dist/load/run.js +255 -0
  43. package/dist/load/script.d.ts +27 -0
  44. package/dist/load/script.js +188 -0
  45. package/dist/mail/extract.d.ts +38 -0
  46. package/dist/mail/extract.js +106 -0
  47. package/dist/mail/index.d.ts +8 -0
  48. package/dist/mail/index.js +11 -0
  49. package/dist/mail/mailpit.d.ts +26 -0
  50. package/dist/mail/mailpit.js +124 -0
  51. package/dist/mail/types.d.ts +38 -0
  52. package/dist/mail/types.js +2 -0
  53. package/dist/matrix/index.d.ts +117 -0
  54. package/dist/matrix/index.js +507 -0
  55. package/dist/perf/vitals.d.ts +118 -0
  56. package/dist/perf/vitals.js +236 -0
  57. package/dist/quality/a11y-scenario.d.ts +68 -0
  58. package/dist/quality/a11y-scenario.js +63 -0
  59. package/dist/quality/gates.d.ts +54 -0
  60. package/dist/quality/gates.js +144 -0
  61. package/dist/quality/hooks.d.ts +2 -0
  62. package/dist/quality/hooks.js +38 -0
  63. package/dist/quality/index.d.ts +4 -0
  64. package/dist/quality/index.js +2 -0
  65. package/dist/quality/perf-scenario.d.ts +132 -0
  66. package/dist/quality/perf-scenario.js +241 -0
  67. package/dist/shots/baselines.d.ts +72 -0
  68. package/dist/shots/baselines.js +201 -0
  69. package/dist/shots/hooks.js +2 -2
  70. package/dist/shots/index.d.ts +1 -0
  71. package/dist/shots/index.js +1 -0
  72. package/dist/shots/narrator.d.ts +10 -2
  73. package/dist/shots/narrator.js +106 -10
  74. package/dist/shots/policy.d.ts +5 -1
  75. package/dist/shots/policy.js +2 -0
  76. package/dist/steps/a11y.steps.d.ts +18 -51
  77. package/dist/steps/a11y.steps.js +6 -107
  78. package/dist/steps/api.steps.js +5 -7
  79. package/dist/steps/browser.steps.d.ts +12 -0
  80. package/dist/steps/browser.steps.js +154 -3
  81. package/dist/steps/canvas.steps.d.ts +9 -0
  82. package/dist/steps/canvas.steps.js +331 -0
  83. package/dist/steps/data.steps.js +17 -5
  84. package/dist/steps/email.steps.d.ts +2 -0
  85. package/dist/steps/email.steps.js +182 -0
  86. package/dist/steps/hooks.steps.d.ts +1 -0
  87. package/dist/steps/hooks.steps.js +3 -1
  88. package/dist/steps/hybrid.steps.js +2 -2
  89. package/dist/steps/index.d.ts +3 -0
  90. package/dist/steps/index.js +3 -0
  91. package/dist/steps/perf.steps.d.ts +3 -129
  92. package/dist/steps/perf.steps.js +4 -227
  93. package/dist/steps/ui.steps.js +5 -0
  94. package/package.json +4 -3
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Per-scenario browser emulation from tags: `@locale:<bcp47>`, `@timezone:<IANA>`,
3
+ * `@theme:<light|dark|no-preference>`, `@viewport:<W>x<H>` and `@device:<name>`.
4
+ *
5
+ * Six locales × dark/light used to mean twelve env yamls, because the only place a browser
6
+ * context's locale and colour scheme could be set was the env's `use:` block. The tags move that
7
+ * axis onto the scenario (or a Feature, or a tagged `Examples:` block), so one env file covers
8
+ * every combination.
9
+ *
10
+ * Locale and timezone are fixed when Playwright creates the browser context and cannot be
11
+ * changed afterwards, which is why these are tags resolved by fixtures before the context opens,
12
+ * rather than steps. Precedence, highest first: the tag, then the env's `use:`, then Playwright's
13
+ * default. `@viewport:` wins over the viewport of `@device:`.
14
+ *
15
+ * Kept pure (no fixtures, no browser) so the precedence and the validation that lint shares with
16
+ * the runtime are testable on their own — the `scenarioSkipReason` precedent.
17
+ */
18
+ export declare const EMULATION_TAGS: readonly ["locale", "timezone", "theme", "viewport", "device"];
19
+ export type EmulationTag = (typeof EMULATION_TAGS)[number];
20
+ export declare const COLOR_SCHEMES: readonly ["light", "dark", "no-preference"];
21
+ export type ColorScheme = (typeof COLOR_SCHEMES)[number];
22
+ export interface ViewportSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+ export interface EmulatedDevice {
27
+ /** The Playwright device descriptor name, e.g. `iPhone 15`. */
28
+ name: string;
29
+ userAgent: string;
30
+ viewport: ViewportSize;
31
+ deviceScaleFactor: number;
32
+ isMobile: boolean;
33
+ hasTouch: boolean;
34
+ }
35
+ export interface Emulation {
36
+ locale?: string;
37
+ timezoneId?: string;
38
+ colorScheme?: ColorScheme;
39
+ viewport?: ViewportSize;
40
+ device?: EmulatedDevice;
41
+ }
42
+ /** `320x640` → `{ width: 320, height: 640 }`, or undefined when it is not a usable size. */
43
+ export declare function parseViewport(value: string): ViewportSize | undefined;
44
+ /**
45
+ * A tag value cannot contain a space, and every Playwright device name does (`iPhone 15`,
46
+ * `Galaxy S9+`). Names match case-insensitively with any run of spaces, hyphens or underscores
47
+ * treated as one separator, so `@device:iPhone-15` and `@device:iphone_15` both find `iPhone 15`.
48
+ */
49
+ export declare function findDevice(value: string): string | undefined;
50
+ /** Why `@<key>:<value>` is unusable, or undefined when it is fine. Shared by lint and runtime. */
51
+ export declare function emulationTagProblem(key: EmulationTag, value: string): string | undefined;
52
+ /**
53
+ * The emulation a scenario's tags ask for. Keys the tags do not mention are absent, so the
54
+ * caller falls back to whatever the env or Playwright would have used.
55
+ *
56
+ * Throws on an unusable value or on two different values for the same key: lint reports both
57
+ * first, and a scenario that reaches the runner anyway must fail loudly rather than run under
58
+ * an emulation nobody asked for.
59
+ */
60
+ export declare function emulationFromTags(tags: readonly string[]): Emulation;
61
+ /** The browser-context option values a scenario gets: tags first, then what the project resolved. */
62
+ export interface ContextEmulationBase {
63
+ locale?: string;
64
+ timezoneId?: string;
65
+ colorScheme?: ColorScheme | null;
66
+ viewport?: ViewportSize | null;
67
+ userAgent?: string;
68
+ deviceScaleFactor?: number;
69
+ isMobile?: boolean;
70
+ hasTouch?: boolean;
71
+ browserName?: string;
72
+ }
73
+ export declare function applyEmulation<T extends ContextEmulationBase>(base: T, emulation: Emulation): Omit<T, 'browserName'>;
74
+ //# sourceMappingURL=emulation.d.ts.map
@@ -0,0 +1,154 @@
1
+ import { devices } from '@playwright/test';
2
+ import { SdodsError } from '../errors.js';
3
+ import { parseTagValues } from './tags.js';
4
+ /**
5
+ * Per-scenario browser emulation from tags: `@locale:<bcp47>`, `@timezone:<IANA>`,
6
+ * `@theme:<light|dark|no-preference>`, `@viewport:<W>x<H>` and `@device:<name>`.
7
+ *
8
+ * Six locales × dark/light used to mean twelve env yamls, because the only place a browser
9
+ * context's locale and colour scheme could be set was the env's `use:` block. The tags move that
10
+ * axis onto the scenario (or a Feature, or a tagged `Examples:` block), so one env file covers
11
+ * every combination.
12
+ *
13
+ * Locale and timezone are fixed when Playwright creates the browser context and cannot be
14
+ * changed afterwards, which is why these are tags resolved by fixtures before the context opens,
15
+ * rather than steps. Precedence, highest first: the tag, then the env's `use:`, then Playwright's
16
+ * default. `@viewport:` wins over the viewport of `@device:`.
17
+ *
18
+ * Kept pure (no fixtures, no browser) so the precedence and the validation that lint shares with
19
+ * the runtime are testable on their own — the `scenarioSkipReason` precedent.
20
+ */
21
+ export const EMULATION_TAGS = ['locale', 'timezone', 'theme', 'viewport', 'device'];
22
+ export const COLOR_SCHEMES = ['light', 'dark', 'no-preference'];
23
+ /** `320x640` → `{ width: 320, height: 640 }`, or undefined when it is not a usable size. */
24
+ export function parseViewport(value) {
25
+ const m = /^(\d{1,5})x(\d{1,5})$/.exec(value.trim());
26
+ if (!m)
27
+ return undefined;
28
+ const width = Number(m[1]);
29
+ const height = Number(m[2]);
30
+ return width > 0 && height > 0 ? { width, height } : undefined;
31
+ }
32
+ /**
33
+ * A tag value cannot contain a space, and every Playwright device name does (`iPhone 15`,
34
+ * `Galaxy S9+`). Names match case-insensitively with any run of spaces, hyphens or underscores
35
+ * treated as one separator, so `@device:iPhone-15` and `@device:iphone_15` both find `iPhone 15`.
36
+ */
37
+ export function findDevice(value) {
38
+ if (value in devices)
39
+ return value;
40
+ const norm = (s) => s.toLowerCase().replace(/[\s_-]+/g, '-');
41
+ const wanted = norm(value);
42
+ return Object.keys(devices).find((name) => norm(name) === wanted);
43
+ }
44
+ /** Why `@<key>:<value>` is unusable, or undefined when it is fine. Shared by lint and runtime. */
45
+ export function emulationTagProblem(key, value) {
46
+ switch (key) {
47
+ case 'locale':
48
+ try {
49
+ // Throws RangeError on anything that is not a structurally valid BCP 47 tag.
50
+ Intl.getCanonicalLocales(value);
51
+ return undefined;
52
+ }
53
+ catch {
54
+ return `@locale:${value} is not a BCP 47 locale (e.g. fr, fr-FR, pt-BR${value.includes('_') ? ' — use a hyphen, not an underscore' : ''}).`;
55
+ }
56
+ case 'timezone':
57
+ try {
58
+ // Accepts every IANA zone and its aliases (UTC, Etc/GMT+1) — `Intl.supportedValuesOf`
59
+ // lists canonical names only, so it would reject `UTC` on some runtimes.
60
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
61
+ return undefined;
62
+ }
63
+ catch {
64
+ return `@timezone:${value} is not an IANA time zone (e.g. Europe/Paris, America/New_York, UTC).`;
65
+ }
66
+ case 'theme':
67
+ return COLOR_SCHEMES.includes(value)
68
+ ? undefined
69
+ : `@theme:${value} must be one of ${COLOR_SCHEMES.join(', ')}.`;
70
+ case 'viewport':
71
+ return parseViewport(value)
72
+ ? undefined
73
+ : `@viewport:${value} must be <width>x<height> in CSS pixels (e.g. @viewport:320x640).`;
74
+ case 'device':
75
+ return findDevice(value)
76
+ ? undefined
77
+ : `@device:${value} is not a Playwright device. Write the descriptor name with hyphens for spaces (e.g. @device:iPhone-15, @device:Pixel-7).`;
78
+ }
79
+ }
80
+ /**
81
+ * The emulation a scenario's tags ask for. Keys the tags do not mention are absent, so the
82
+ * caller falls back to whatever the env or Playwright would have used.
83
+ *
84
+ * Throws on an unusable value or on two different values for the same key: lint reports both
85
+ * first, and a scenario that reaches the runner anyway must fail loudly rather than run under
86
+ * an emulation nobody asked for.
87
+ */
88
+ export function emulationFromTags(tags) {
89
+ const one = (key) => {
90
+ const values = [...new Set(parseTagValues(tags, key))];
91
+ if (values.length > 1)
92
+ throw new SdodsError('CONFIG_INVALID', `Conflicting tags @${key}:${values.join(` and @${key}:`)} on one scenario.`, {
93
+ hint: `A scenario runs in one browser context, so it takes one @${key}: value. Tags on the Feature apply to every scenario in it — remove the duplicate from the scenario or the Feature.`,
94
+ });
95
+ const value = values[0];
96
+ if (value === undefined)
97
+ return undefined;
98
+ const problem = emulationTagProblem(key, value);
99
+ if (problem)
100
+ throw new SdodsError('CONFIG_INVALID', problem, { hint: 'Run `sdods lint`.' });
101
+ return value;
102
+ };
103
+ const out = {};
104
+ const locale = one('locale');
105
+ // Canonical form (`fr-fr` → `fr-FR`), so the context and `I use the locale` agree on spelling.
106
+ if (locale)
107
+ out.locale = Intl.getCanonicalLocales(locale)[0];
108
+ const timezone = one('timezone');
109
+ if (timezone)
110
+ out.timezoneId = timezone;
111
+ const theme = one('theme');
112
+ if (theme)
113
+ out.colorScheme = theme;
114
+ const viewport = one('viewport');
115
+ if (viewport)
116
+ out.viewport = parseViewport(viewport);
117
+ const deviceTag = one('device');
118
+ if (deviceTag) {
119
+ const name = findDevice(deviceTag);
120
+ const d = devices[name];
121
+ out.device = {
122
+ name,
123
+ userAgent: d.userAgent,
124
+ viewport: d.viewport,
125
+ deviceScaleFactor: d.deviceScaleFactor,
126
+ isMobile: d.isMobile,
127
+ hasTouch: d.hasTouch,
128
+ };
129
+ }
130
+ return out;
131
+ }
132
+ export function applyEmulation(base, emulation) {
133
+ const { browserName, ...rest } = base;
134
+ const d = emulation.device;
135
+ return {
136
+ ...rest,
137
+ ...(emulation.locale !== undefined ? { locale: emulation.locale } : {}),
138
+ ...(emulation.timezoneId !== undefined ? { timezoneId: emulation.timezoneId } : {}),
139
+ ...(emulation.colorScheme !== undefined ? { colorScheme: emulation.colorScheme } : {}),
140
+ ...(d
141
+ ? {
142
+ viewport: d.viewport,
143
+ userAgent: d.userAgent,
144
+ deviceScaleFactor: d.deviceScaleFactor,
145
+ hasTouch: d.hasTouch,
146
+ // Firefox rejects `isMobile` at context creation. The engine is the run target's, so a
147
+ // device tag on the firefox target emulates size, agent and touch only.
148
+ ...(browserName === 'firefox' ? {} : { isMobile: d.isMobile }),
149
+ }
150
+ : {}),
151
+ ...(emulation.viewport !== undefined ? { viewport: emulation.viewport } : {}),
152
+ };
153
+ }
154
+ //# sourceMappingURL=emulation.js.map
@@ -7,4 +7,5 @@ export * from './resolve.js';
7
7
  export * from './registry.js';
8
8
  export * from './workspace.js';
9
9
  export * from './runner.js';
10
+ export * from './emulation.js';
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -7,4 +7,5 @@ export * from './resolve.js';
7
7
  export * from './registry.js';
8
8
  export * from './workspace.js';
9
9
  export * from './runner.js';
10
+ export * from './emulation.js';
10
11
  //# sourceMappingURL=index.js.map
@@ -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 = {
@@ -228,7 +228,10 @@ export function buildRunnerConfig(registry, sel = {}) {
228
228
  ...browserUse(browser, p.channel),
229
229
  baseURL: cfg.env.ui.baseUrl,
230
230
  testIdAttribute: p.testIdAttribute,
231
- viewport: browser.startsWith('mobile') ? undefined : p.screenshots.viewport,
231
+ // Mobile targets keep their device descriptor's viewport. The key must be ABSENT, not
232
+ // `undefined`: Playwright treats an explicit undefined option as "use the default",
233
+ // so `viewport: undefined` reset Pixel 7 and iPhone 15 to a 1280x720 desktop window.
234
+ ...(browser.startsWith('mobile') ? {} : { viewport: p.screenshots.viewport }),
232
235
  ...envUse,
233
236
  ...evidenceUse,
234
237
  sdods: { project: p.slug, layer, browser },
@@ -5,7 +5,7 @@ export declare const RUNNER_SPECIAL_TAGS: RegExp;
5
5
  /** @deprecated Use {@link RUNNER_SPECIAL_TAGS}. Removed in the next minor. */
6
6
  export declare const PLAYWRIGHT_BDD_SPECIAL: RegExp;
7
7
  export declare const VALUE_TAG: RegExp;
8
- export declare const KNOWN_VALUE_TAGS: readonly ["env", "user", "data", "har", "jira", "github", "skip", "title", "flag"];
8
+ export declare const KNOWN_VALUE_TAGS: readonly ["env", "user", "data", "har", "jira", "github", "skip", "title", "flag", "matrix", "req", "locale", "timezone", "theme", "viewport", "device"];
9
9
  /**
10
10
  * Values `@skip:<browser>` accepts. Derived from the schema rather than copied, so adding a browser
11
11
  * to `BrowserSchema` is the only edit needed — and the lint message lists the new name for free.
@@ -17,6 +17,13 @@ export const KNOWN_VALUE_TAGS = [
17
17
  'skip',
18
18
  'title',
19
19
  'flag',
20
+ 'matrix',
21
+ 'req',
22
+ 'locale',
23
+ 'timezone',
24
+ 'theme',
25
+ 'viewport',
26
+ 'device',
20
27
  ];
21
28
  /**
22
29
  * Values `@skip:<browser>` accepts. Derived from the schema rather than copied, so adding a browser
@@ -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<{