@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
@@ -1,4 +1,5 @@
1
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, writeSync, } from 'node:fs';
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync, } from 'node:fs';
2
+ import { cpus } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  import { SdodsError } from '../errors.js';
4
5
  import { Logger } from '../logger.js';
@@ -82,9 +83,46 @@ export class FileLeaseStore {
82
83
  return out;
83
84
  }
84
85
  }
86
+ /** `data.userPool.waitMs` when unset and leases are held per worker. */
87
+ export const DEFAULT_LEASE_WAIT_MS = 30_000;
88
+ const POLL_MS = 500;
89
+ /** A waiter refreshes its ticket every poll; one untouched this long belongs to a waiter that is gone. */
90
+ const TICKET_STALE_MS = 10_000;
91
+ /**
92
+ * The worker count Playwright will actually use: `--workers` / `SDODS_WORKERS` when set (both land
93
+ * in `runtime.workers`), else Playwright's own default of half the logical cores.
94
+ *
95
+ * Every browser and layer of one `sdods run` shares this pool: the CLI starts ONE `playwright test`
96
+ * with a `--project` per run target, and Playwright's `workers` caps the whole invocation.
97
+ */
98
+ export function effectiveWorkers(workers) {
99
+ return workers ?? Math.max(1, Math.floor(cpus().length / 2));
100
+ }
101
+ /** How long a lease request waits for a free account (see `data.userPool.waitMs`). */
102
+ export function leaseWaitMs(pool) {
103
+ if (pool.waitMs !== undefined)
104
+ return pool.waitMs;
105
+ return pool.leaseScope === 'scenario' ? pool.leaseTtlMs : DEFAULT_LEASE_WAIT_MS;
106
+ }
107
+ /**
108
+ * With `leaseScope: scenario`, waiting for an account is how one account serialises the scenarios
109
+ * that need it, so the wait must not count against the scenario's own timeout: a scenario third in
110
+ * line would otherwise fail having done nothing. The timeout is raised by the longest possible wait
111
+ * before leasing, then settled to what was actually waited.
112
+ */
113
+ export function scenarioLeaseClock(pool, testInfo) {
114
+ const base = testInfo.timeout;
115
+ if (pool?.leaseScope !== 'scenario' || pool.mode === 'shared' || base <= 0) {
116
+ return { settle: () => undefined };
117
+ }
118
+ const started = Date.now();
119
+ testInfo.setTimeout(base + leaseWaitMs(pool));
120
+ return { settle: () => testInfo.setTimeout(base + (Date.now() - started)) };
121
+ }
85
122
  /**
86
123
  * Pool of accounts from the configured dataset, partitioned by role and capped by env.users.poolSize.
87
- * Leases are keyed by owner (`runId:shardOffset+parallelIndex`) and released on worker teardown.
124
+ * Leases are keyed by owner (`runId:shardOffset+parallelIndex`) and released on worker teardown, or
125
+ * at scenario teardown with `leaseScope: scenario`.
88
126
  */
89
127
  export class FileUserPool {
90
128
  config;
@@ -93,18 +131,28 @@ export class FileUserPool {
93
131
  log = new Logger('pool');
94
132
  leased = new Map(); // by role
95
133
  rows;
96
- store;
134
+ storeInstance;
135
+ leaseDir;
97
136
  constructor(config, data, opts) {
98
137
  this.config = config;
99
138
  this.data = data;
100
139
  this.opts = opts;
101
- this.store =
102
- opts.store ??
103
- new FileLeaseStore(join(config.runtime.artifactsDir, '..', 'leases', config.project.slug, config.env.name));
140
+ this.leaseDir = join(config.runtime.artifactsDir, '..', 'leases', config.project.slug, config.env.name);
141
+ this.storeInstance = opts.store;
142
+ }
143
+ /**
144
+ * Created on first use. Every scenario depends on the pool (the scenario-scope release fixture is
145
+ * automatic), and a project that never leases must not grow a lease directory for it.
146
+ */
147
+ get store() {
148
+ return (this.storeInstance ??= new FileLeaseStore(this.leaseDir));
104
149
  }
105
150
  static ownerFor(config, parallelIndex) {
106
151
  const shard = config.runtime.shard;
107
- const offset = shard ? (shard.current - 1) * (config.runtime.workers ?? 1) : 0;
152
+ // Shards each number their workers from 0, so the offset must span a whole shard's workers.
153
+ // It used `workers ?? 1`, which let shard 2's worker 0 share an owner with shard 1's worker 1
154
+ // whenever --workers was left to Playwright's default.
155
+ const offset = shard ? (shard.current - 1) * effectiveWorkers(config.runtime.workers) : 0;
108
156
  return `${config.runtime.runId}:${offset + parallelIndex}`;
109
157
  }
110
158
  async users() {
@@ -161,42 +209,122 @@ export class FileUserPool {
161
209
  return user;
162
210
  }
163
211
  const ttl = pool.leaseTtlMs;
164
- const waitMs = this.opts.waitMs ?? pool.waitMs;
212
+ const waitMs = this.opts.waitMs ?? leaseWaitMs(pool);
165
213
  const started = Date.now();
166
- while (true) {
167
- for (const { r, index } of candidates) {
168
- const id = String(r.id ?? r.username ?? index);
169
- if (await this.store.tryAcquire(id, this.opts.owner, ttl)) {
170
- const user = {
171
- id,
172
- username: String(r.username ?? id),
173
- password: String(r.password ?? ''),
174
- role,
175
- index,
176
- extra: r,
177
- leaseKey: id,
178
- owner: this.opts.owner,
179
- };
180
- this.leased.set(role, user);
181
- this.log.debug(`leased ${user.username} (${role}) for ${this.opts.owner}`);
182
- return user;
214
+ const ticket = this.queueTicket(role, started);
215
+ try {
216
+ while (true) {
217
+ // First come, first served. Without a queue the worker that just released an account won
218
+ // it straight back: its next scenario asks within milliseconds while every waiter polls
219
+ // every 500ms, so under `leaseScope: scenario` the same waiters lost round after round and
220
+ // failed on the timeout while the account changed hands the whole time.
221
+ if (ticket.ahead() < candidates.length) {
222
+ for (const { r, index } of candidates) {
223
+ const id = String(r.id ?? r.username ?? index);
224
+ if (await this.store.tryAcquire(id, this.opts.owner, ttl)) {
225
+ const user = {
226
+ id,
227
+ username: String(r.username ?? id),
228
+ password: String(r.password ?? ''),
229
+ role,
230
+ index,
231
+ extra: r,
232
+ leaseKey: id,
233
+ owner: this.opts.owner,
234
+ };
235
+ this.leased.set(role, user);
236
+ this.log.debug(`leased ${user.username} (${role}) for ${this.opts.owner}`);
237
+ return user;
238
+ }
239
+ }
183
240
  }
241
+ if (Date.now() - started > waitMs) {
242
+ const owners = await this.store.owners();
243
+ throw new SdodsError('USER_POOL_EXHAUSTED', `All ${candidates.length} user(s) with role "${role}" are leased.`, {
244
+ hint: `Owners: ${JSON.stringify(owners)}. Waited ${Math.round(waitMs / 1000)}s. ` +
245
+ `Four ways out, in the order worth trying: (1) if these scenarios do not ` +
246
+ `mutate user-scoped state, set data.userPool.mode: shared — one account ` +
247
+ `then serves every worker, which is what a read-only suite needs; ` +
248
+ `(2) add more accounts with role "${role}" to dataset "${pool.dataset}"; ` +
249
+ `(3) set data.userPool.leaseScope: scenario, so an account is released when ` +
250
+ `each scenario ends rather than when its worker exits, or raise ` +
251
+ `data.userPool.waitMs (currently ${waitMs}ms); (4) lower --workers. ` +
252
+ `Note env.users.poolSize slices the dataset BEFORE role filtering, so a ` +
253
+ `small value can starve a role on its own.`,
254
+ });
255
+ }
256
+ await new Promise((r) => setTimeout(r, POLL_MS));
257
+ ticket.touch();
184
258
  }
185
- if (Date.now() - started > waitMs) {
186
- const owners = await this.store.owners();
187
- throw new SdodsError('USER_POOL_EXHAUSTED', `All ${candidates.length} user(s) with role "${role}" are leased.`, {
188
- hint: `Owners: ${JSON.stringify(owners)}. Waited ${Math.round(waitMs / 1000)}s. ` +
189
- `Four ways out, in the order worth trying: (1) if these scenarios do not ` +
190
- `mutate user-scoped state, set data.userPool.mode: shared — one account ` +
191
- `then serves every worker, which is what a read-only suite needs; ` +
192
- `(2) add more accounts with role "${role}" to dataset "${pool.dataset}"; ` +
193
- `(3) raise data.userPool.waitMs (currently ${waitMs}ms); (4) lower --workers. ` +
194
- `Note env.users.poolSize slices the dataset BEFORE role filtering, so a ` +
195
- `small value can starve a role on its own.`,
196
- });
197
- }
198
- await new Promise((r) => setTimeout(r, 500));
199
259
  }
260
+ finally {
261
+ ticket.drop();
262
+ }
263
+ }
264
+ /**
265
+ * A place in line for `role`: an empty file named by arrival time and owner, refreshed while its
266
+ * waiter polls. `ahead()` counts live tickets that arrived earlier, and a waiter only tries to
267
+ * acquire while fewer are ahead of it than the role has accounts. A ticket nobody refreshes (its
268
+ * worker crashed) goes stale and stops holding the line.
269
+ */
270
+ queueTicket(role, arrivedAt) {
271
+ const dir = join(this.opts.queueDir ?? join(this.leaseDir, '.queue'), encodeURIComponent(role));
272
+ const name = `${String(arrivedAt).padStart(15, '0')}-${encodeURIComponent(this.opts.owner)}`;
273
+ const file = join(dir, name);
274
+ const write = () => {
275
+ try {
276
+ mkdirSync(dir, { recursive: true });
277
+ writeFileSync(file, '');
278
+ }
279
+ catch {
280
+ /* a queue that cannot be written degrades to unordered polling */
281
+ }
282
+ };
283
+ write();
284
+ return {
285
+ ahead: () => {
286
+ let names;
287
+ try {
288
+ names = readdirSync(dir);
289
+ }
290
+ catch {
291
+ return 0;
292
+ }
293
+ let ahead = 0;
294
+ for (const other of names) {
295
+ if (other >= name)
296
+ continue;
297
+ try {
298
+ if (Date.now() - statSync(join(dir, other)).mtimeMs > TICKET_STALE_MS) {
299
+ unlinkSync(join(dir, other));
300
+ continue;
301
+ }
302
+ }
303
+ catch {
304
+ continue; // dropped by its owner between readdir and stat
305
+ }
306
+ ahead++;
307
+ }
308
+ return ahead;
309
+ },
310
+ touch: () => {
311
+ try {
312
+ const now = new Date();
313
+ utimesSync(file, now, now);
314
+ }
315
+ catch {
316
+ write(); // removed as stale while this worker was blocked: back in, same place
317
+ }
318
+ },
319
+ drop: () => {
320
+ try {
321
+ unlinkSync(file);
322
+ }
323
+ catch {
324
+ /* already gone */
325
+ }
326
+ },
327
+ };
200
328
  }
201
329
  async release(user) {
202
330
  // A shared user holds no lease (leaseKey is empty). Releasing it would be at
@@ -206,7 +334,9 @@ export class FileUserPool {
206
334
  this.leased.delete(user.role);
207
335
  }
208
336
  async releaseAll() {
209
- await this.store.releaseAll(this.opts.owner);
337
+ // A store that was never created holds nothing to release.
338
+ if (this.storeInstance)
339
+ await this.storeInstance.releaseAll(this.opts.owner);
210
340
  this.leased.clear();
211
341
  }
212
342
  async status() {
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Structured errors: every CLI/MCP/server surface renders `{ code, message, hint, docsUrl }`. */
2
- export type SdodsErrorCode = 'CONFIG_INVALID' | 'CONFIG_NOT_FOUND' | 'CONFIG_SECRET_LITERAL' | 'CONFIG_UNRESOLVED_VAR' | 'PROJECT_NOT_FOUND' | 'ENV_NOT_FOUND' | 'DATASET_NOT_FOUND' | 'DATASET_ROW_NOT_FOUND' | 'USER_POOL_EXHAUSTED' | 'HEAL_FAILED' | 'LINT_FAILED' | 'RUN_FAILED' | 'HAR_MISS' | 'DB_REQUIRED' | 'AUTH_FAILED' | 'NOT_SUPPORTED' | 'INTERNAL';
2
+ export type SdodsErrorCode = 'CONFIG_INVALID' | 'CONFIG_NOT_FOUND' | 'CONFIG_SECRET_LITERAL' | 'CONFIG_UNRESOLVED_VAR' | 'PROJECT_NOT_FOUND' | 'ENV_NOT_FOUND' | 'DATASET_NOT_FOUND' | 'DATASET_ROW_NOT_FOUND' | 'USER_POOL_EXHAUSTED' | 'USER_POOL_TOO_SMALL' | 'HEAL_FAILED' | 'LINT_FAILED' | 'RUN_FAILED' | 'GATE_FAILED' | 'HAR_MISS' | 'DB_REQUIRED' | 'MAIL_REQUIRED' | 'MAIL_NOT_RECEIVED' | 'AUTH_FAILED' | 'NOT_SUPPORTED' | 'INTERNAL';
3
3
  export declare const DOCS_BASE_URL = "https://docs.sdods.com";
4
4
  export interface SdodsErrorOptions {
5
5
  hint?: string;
@@ -1,8 +1,26 @@
1
1
  import type { ApiSnapshot } from '@sdods/contracts';
2
+ /**
3
+ * A `Map` of HTTP header names to values whose names are case-insensitive, as HTTP's are.
4
+ *
5
+ * Names are stored lower-cased, so `set('X-API-Key')` followed by `delete('x-api-key')` removes
6
+ * the header, `get('cookie')` finds a header set as `Cookie`, and when a scenario sets the same
7
+ * header under two casings the last `set` wins. The client lower-cases names when it builds a
8
+ * request anyway, so what goes on the wire and into the evidence is unchanged.
9
+ *
10
+ * A plain `new Map(headers)` clone keeps the lower-cased names; restore it by `set`-ting each
11
+ * entry back into this map rather than replacing it.
12
+ */
13
+ export declare class HeaderMap extends Map<string, string> {
14
+ set(name: string, value: string): this;
15
+ get(name: string): string | undefined;
16
+ has(name: string): boolean;
17
+ delete(name: string): boolean;
18
+ }
2
19
  /** Per-scenario API state: last response, chained variables, pending headers/query. No module globals. */
3
20
  export declare class ApiContext {
4
21
  readonly vars: VarStore;
5
- readonly headers: Map<string, string>;
22
+ /** Pending request headers. Names are case-insensitive (stored lower-cased). */
23
+ readonly headers: HeaderMap;
6
24
  readonly query: Map<string, string>;
7
25
  readonly history: ApiSnapshot[];
8
26
  /**
@@ -1,7 +1,34 @@
1
+ /**
2
+ * A `Map` of HTTP header names to values whose names are case-insensitive, as HTTP's are.
3
+ *
4
+ * Names are stored lower-cased, so `set('X-API-Key')` followed by `delete('x-api-key')` removes
5
+ * the header, `get('cookie')` finds a header set as `Cookie`, and when a scenario sets the same
6
+ * header under two casings the last `set` wins. The client lower-cases names when it builds a
7
+ * request anyway, so what goes on the wire and into the evidence is unchanged.
8
+ *
9
+ * A plain `new Map(headers)` clone keeps the lower-cased names; restore it by `set`-ting each
10
+ * entry back into this map rather than replacing it.
11
+ */
12
+ export class HeaderMap extends Map {
13
+ // No constructor: Map's constructor adds iterable entries through `this.set`, so they normalise too.
14
+ set(name, value) {
15
+ return super.set(name.toLowerCase(), value);
16
+ }
17
+ get(name) {
18
+ return super.get(name.toLowerCase());
19
+ }
20
+ has(name) {
21
+ return super.has(name.toLowerCase());
22
+ }
23
+ delete(name) {
24
+ return super.delete(name.toLowerCase());
25
+ }
26
+ }
1
27
  /** Per-scenario API state: last response, chained variables, pending headers/query. No module globals. */
2
28
  export class ApiContext {
3
29
  vars = new VarStore();
4
- headers = new Map();
30
+ /** Pending request headers. Names are case-insensitive (stored lower-cased). */
31
+ headers = new HeaderMap();
5
32
  query = new Map();
6
33
  history = [];
7
34
  /**
@@ -17,9 +17,20 @@ export interface ScenarioInit {
17
17
  export declare class ScenarioMeta {
18
18
  readonly data: ScenarioMetaDto;
19
19
  readonly dir: string;
20
+ /** the run directory the scenario directory lives in */
21
+ readonly runDir: string;
22
+ /** root of the SDODS project (`projects/<slug>`) */
23
+ readonly projectRoot: string;
20
24
  readonly suiteTag?: string;
21
25
  constructor(init: ScenarioInit);
26
+ /**
27
+ * The first failure an after-scenario hook (`@a11y`, `@perf`) raised. Playwright marks the test
28
+ * failed only once every after-hook has returned, so `sdods:finalize` would otherwise record a
29
+ * scenario those hooks failed as passed.
30
+ */
31
+ hookFailure?: string;
22
32
  get fingerprint(): string;
33
+ noteFailure(message: string): void;
23
34
  file(rel: string): string;
24
35
  appendJsonl(rel: string, obj: unknown): void;
25
36
  write(): void;
@@ -7,6 +7,10 @@ import { moduleForFeature } from '../config/workspace.js';
7
7
  export class ScenarioMeta {
8
8
  data;
9
9
  dir;
10
+ /** the run directory the scenario directory lives in */
11
+ runDir;
12
+ /** root of the SDODS project (`projects/<slug>`) */
13
+ projectRoot;
10
14
  suiteTag;
11
15
  constructor(init) {
12
16
  const { config, testInfo, tags } = init;
@@ -21,6 +25,8 @@ export class ScenarioMeta {
21
25
  exampleIndex: null,
22
26
  layer,
23
27
  });
28
+ this.runDir = config.runtime.runDir;
29
+ this.projectRoot = config.project.root;
24
30
  this.dir = join(config.runtime.runDir, config.project.slug, fp, `r${testInfo.retry}`);
25
31
  this.suiteTag = suiteOfTags(tags, config.project.tags.suites.map((s) => `@${s}`));
26
32
  this.data = {
@@ -48,9 +54,18 @@ export class ScenarioMeta {
48
54
  mkdirSync(this.dir, { recursive: true });
49
55
  this.write();
50
56
  }
57
+ /**
58
+ * The first failure an after-scenario hook (`@a11y`, `@perf`) raised. Playwright marks the test
59
+ * failed only once every after-hook has returned, so `sdods:finalize` would otherwise record a
60
+ * scenario those hooks failed as passed.
61
+ */
62
+ hookFailure;
51
63
  get fingerprint() {
52
64
  return this.data.fingerprint;
53
65
  }
66
+ noteFailure(message) {
67
+ this.hookFailure ??= message;
68
+ }
54
69
  file(rel) {
55
70
  const f = join(this.dir, rel);
56
71
  const parent = join(f, '..');
@@ -2,10 +2,11 @@ import { join } from 'node:path';
2
2
  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
+ import { applyEmulation, emulationFromTags } from '../config/emulation.js';
5
6
  import { noopAuth } from '../auth/index.js';
6
7
  import { ApiClient, isolatedRequestFactory } from '../api/client.js';
7
8
  import { CompositeDataProvider } from '../data/provider.js';
8
- import { FileUserPool } from '../data/user-pool.js';
9
+ import { FileUserPool, scenarioLeaseClock } from '../data/user-pool.js';
9
10
  import { apiHarForScenario } from '../har/hooks.js';
10
11
  import { Healer } from '../heal/healer.js';
11
12
  import { HealHistory } from '../heal/history.js';
@@ -187,16 +188,72 @@ export const test = base.extend({
187
188
  // `$sdodsTagGate` first, and it is not unused: it is here to force the ordering.
188
189
  // Leasing an account for a scenario that the tag gate is about to skip burns a
189
190
  // 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
+ user: async ({ $sdodsTagGate, userPool, config, $tags }, use, testInfo) => {
191
192
  void $sdodsTagGate;
192
193
  const role = parseTagValue($tags, 'user');
193
- await use(role ? await userPool.lease(role, testInfo.parallelIndex) : undefined);
194
+ if (!role)
195
+ return use(undefined);
196
+ const clock = scenarioLeaseClock(config.project.data.userPool, testInfo);
197
+ const leased = await userPool.lease(role, testInfo.parallelIndex);
198
+ clock.settle();
199
+ await use(leased);
194
200
  },
201
+ /**
202
+ * `data.userPool.leaseScope: scenario` (#153): hand back every account this worker leased once
203
+ * the scenario is over — the `@user:` lease above and the `I use a leased user …` steps alike.
204
+ * A worker runs one scenario at a time, so what it holds at teardown is exactly this scenario's.
205
+ *
206
+ * Automatic and dependent only on worker fixtures, so it is set up before and torn down after
207
+ * everything that could still be signed in as the account (page, context, storageState).
208
+ */
209
+ $sdodsScenarioLeases: [
210
+ async ({ config, userPool }, use) => {
211
+ await use();
212
+ if (config.project.data.userPool?.leaseScope === 'scenario')
213
+ await userPool.releaseAll();
214
+ },
215
+ { auto: true },
216
+ ],
195
217
  storageState: async ({ user, authCache, auth, sdods, browser, config }, use) => {
196
218
  if (!user || sdods.layer === 'api' || !config.project.auth.storageState)
197
219
  return use(undefined);
198
220
  await use(await authCache.ensure(user, auth, browser));
199
221
  },
222
+ // ── per-scenario emulation: @locale: @timezone: @theme: @viewport: @device: ────
223
+ //
224
+ // Mirrors `storageState` above: Playwright builds the browser context from these option
225
+ // fixtures, so overriding them is the one place a tag can reach the context BEFORE it opens —
226
+ // which locale and timezone require, because neither can change on a live context. Each
227
+ // override takes its own previous value (the env's `use:` via the runner project, or
228
+ // Playwright's default) and replaces it only when the scenario is tagged, so tags win over the
229
+ // env and an untagged scenario is untouched.
230
+ $sdodsEmulation: async ({ $tags }, use) => {
231
+ await use(emulationFromTags($tags));
232
+ },
233
+ locale: async ({ locale, $sdodsEmulation }, use) => {
234
+ await use(applyEmulation({ locale }, $sdodsEmulation).locale);
235
+ },
236
+ timezoneId: async ({ timezoneId, $sdodsEmulation }, use) => {
237
+ await use(applyEmulation({ timezoneId }, $sdodsEmulation).timezoneId);
238
+ },
239
+ colorScheme: async ({ colorScheme, $sdodsEmulation }, use) => {
240
+ await use(applyEmulation({ colorScheme }, $sdodsEmulation).colorScheme);
241
+ },
242
+ viewport: async ({ viewport, $sdodsEmulation }, use) => {
243
+ await use(applyEmulation({ viewport }, $sdodsEmulation).viewport);
244
+ },
245
+ userAgent: async ({ userAgent, $sdodsEmulation }, use) => {
246
+ await use(applyEmulation({ userAgent }, $sdodsEmulation).userAgent);
247
+ },
248
+ deviceScaleFactor: async ({ deviceScaleFactor, $sdodsEmulation }, use) => {
249
+ await use(applyEmulation({ deviceScaleFactor }, $sdodsEmulation).deviceScaleFactor);
250
+ },
251
+ hasTouch: async ({ hasTouch, $sdodsEmulation }, use) => {
252
+ await use(applyEmulation({ hasTouch }, $sdodsEmulation).hasTouch);
253
+ },
254
+ isMobile: async ({ isMobile, browserName, $sdodsEmulation }, use) => {
255
+ await use(applyEmulation({ isMobile, browserName }, $sdodsEmulation).isMobile);
256
+ },
200
257
  heal: async ({ config, healHistory, scenario, $bddContext }, use, testInfo) => {
201
258
  await use(new Healer({
202
259
  config: config.project.heal,
@@ -1,4 +1,5 @@
1
1
  import type { BrowserName, Layer } from '@sdods/contracts';
2
+ import type { Emulation } from '../config/emulation.js';
2
3
  import type { ProjectRegistry } from '../config/registry.js';
3
4
  import type { HarMode, ResolvedConfig } from '../config/resolve.js';
4
5
  import type { EnvConfig } from '@sdods/contracts';
@@ -48,5 +49,9 @@ export interface TestFixtures {
48
49
  $sdodsAnnotations: void;
49
50
  /** auto fixture: applies @env:, @skip:<browser>, @quarantine and @flag: */
50
51
  $sdodsTagGate: void;
52
+ /** auto fixture: releases the scenario's pool leases when `leaseScope: scenario` */
53
+ $sdodsScenarioLeases: void;
54
+ /** What the scenario's @locale: @timezone: @theme: @viewport: @device: tags ask for. */
55
+ $sdodsEmulation: Emulation;
51
56
  }
52
57
  //# sourceMappingURL=types.d.ts.map
package/dist/index.d.ts CHANGED
@@ -8,11 +8,15 @@ export * from './heal/index.js';
8
8
  export * from './shots/index.js';
9
9
  export * from './lint/index.js';
10
10
  export * from './evidence/index.js';
11
+ export * from './mail/index.js';
11
12
  export { BasePage } from './pages/base-page.js';
12
13
  export { PageRegistry } from './fixtures/pages.js';
13
14
  export { AuthStateCache } from './fixtures/auth.js';
14
15
  export { ScenarioMeta } from './fixtures/scenario.js';
16
+ export { HeaderMap } from './fixtures/api-context.js';
15
17
  export type { TestFixtures, WorkerFixtures, SdodsOption } from './fixtures/types.js';
16
18
  export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './steps/glob.js';
17
19
  export { VERSION } from './version.js';
20
+ export * from './matrix/index.js';
21
+ export * from './quality/index.js';
18
22
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -8,10 +8,14 @@ export * from './heal/index.js';
8
8
  export * from './shots/index.js';
9
9
  export * from './lint/index.js';
10
10
  export * from './evidence/index.js';
11
+ export * from './mail/index.js';
11
12
  export { BasePage } from './pages/base-page.js';
12
13
  export { PageRegistry } from './fixtures/pages.js';
13
14
  export { AuthStateCache } from './fixtures/auth.js';
14
15
  export { ScenarioMeta } from './fixtures/scenario.js';
16
+ export { HeaderMap } from './fixtures/api-context.js';
15
17
  export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './steps/glob.js';
16
18
  export { VERSION } from './version.js';
19
+ export * from './matrix/index.js';
20
+ export * from './quality/index.js';
17
21
  //# sourceMappingURL=index.js.map
@@ -3,8 +3,11 @@ import { join, relative, resolve } from 'node:path';
3
3
  import { execa } from 'execa';
4
4
  import { BROWSERS_FOR_SKIP, KNOWN_VALUE_TAGS, LAYER_TAGS, RUNNER_SPECIAL_TAGS, VALUE_TAG, taxonomyFromProject, } from '../config/tags.js';
5
5
  import { moduleForFeature } from '../config/workspace.js';
6
+ import { emulationTagProblem } from '../config/emulation.js';
6
7
  import { parseFeatureFile, scenariosOf } from './gherkin.js';
8
+ import { projectRequirements } from '../analyze/traceability.js';
7
9
  import { checkStepAmbiguity } from './steps.js';
10
+ import { ROLES_MATRIX_FILE, planMatrixExpansion } from '../matrix/index.js';
8
11
  export * from './gherkin.js';
9
12
  export function listFeatureFiles(root) {
10
13
  const out = [];
@@ -32,6 +35,21 @@ export async function lintProject(opts) {
32
35
  const warnings = [];
33
36
  const rel = (f) => relative(project.root, f).replace(/\\/g, '/');
34
37
  const parsedFeatures = [];
38
+ const matrixPlan = planMatrixExpansion(project, files);
39
+ // `@req:<id>` is opaque unless the project lists its requirements; then an id must be in the list.
40
+ let declaredReqs = null;
41
+ try {
42
+ const defs = projectRequirements(project);
43
+ declaredReqs = defs ? new Set(defs.map((d) => d.id)) : null;
44
+ }
45
+ catch (e) {
46
+ errors.push({
47
+ severity: 'error',
48
+ rule: 'traceability/requirements',
49
+ message: e.message,
50
+ file: 'sdods.project.yaml',
51
+ });
52
+ }
35
53
  for (const file of files) {
36
54
  const parsed = parseFeatureFile(file);
37
55
  parsedFeatures.push(parsed);
@@ -158,6 +176,31 @@ export async function lintProject(opts) {
158
176
  ...loc,
159
177
  });
160
178
  break;
179
+ case 'matrix':
180
+ if (!matrixPlan.loaded)
181
+ errors.push({
182
+ severity: 'error',
183
+ rule: 'tags/matrix',
184
+ message: `@matrix:${value} needs a ${ROLES_MATRIX_FILE} in the project.`,
185
+ ...loc,
186
+ });
187
+ else if (!matrixPlan.loaded.declared.includes(value))
188
+ errors.push({
189
+ severity: 'error',
190
+ rule: 'tags/matrix',
191
+ message: `@matrix:${value} is not a matrix in ${ROLES_MATRIX_FILE} (${matrixPlan.loaded.declared.join(', ') || 'none'}).`,
192
+ ...loc,
193
+ });
194
+ break;
195
+ case 'req':
196
+ if (declaredReqs && !declaredReqs.has(value))
197
+ errors.push({
198
+ severity: 'error',
199
+ rule: 'tags/req',
200
+ message: `@req:${value} is not listed in ${project.traceability?.requirements}.`,
201
+ ...loc,
202
+ });
203
+ break;
161
204
  case 'skip':
162
205
  if (!BROWSERS_FOR_SKIP.includes(value))
163
206
  errors.push({
@@ -167,6 +210,28 @@ export async function lintProject(opts) {
167
210
  ...loc,
168
211
  });
169
212
  break;
213
+ case 'locale':
214
+ case 'timezone':
215
+ case 'theme':
216
+ case 'viewport':
217
+ case 'device': {
218
+ const problem = emulationTagProblem(key, value);
219
+ if (problem)
220
+ errors.push({ severity: 'error', rule: `tags/${key}`, message: problem, ...loc });
221
+ // One browser context per scenario, so one value per key (Feature tags included).
222
+ // Outlines are exempt: their tag list folds in every Examples block, and one block
223
+ // per locale (`@locale:fr` / `@locale:de`) is exactly the pattern these tags enable.
224
+ // The runner still refuses a real conflict on a single example.
225
+ const distinct = [...new Set(sc.tags.filter((t) => t.startsWith(`@${key}:`)))];
226
+ if (!sc.isOutline && distinct.length > 1 && distinct[0] === tag)
227
+ errors.push({
228
+ severity: 'error',
229
+ rule: `tags/${key}`,
230
+ message: `Conflicting ${distinct.join(' and ')}: a scenario takes one @${key}: value.`,
231
+ ...loc,
232
+ });
233
+ break;
234
+ }
170
235
  default:
171
236
  if (!KNOWN_VALUE_TAGS.includes(key))
172
237
  warnings.push({
@@ -185,6 +250,14 @@ export async function lintProject(opts) {
185
250
  ...loc,
186
251
  });
187
252
  }
253
+ if (project.traceability?.require && !sc.tags.some((t) => t.startsWith('@req:'))) {
254
+ errors.push({
255
+ severity: 'error',
256
+ rule: 'tags/req-missing',
257
+ message: 'Scenario carries no @req:<id> tag (traceability.require is on). Tag the scenario, or its Feature to cover every scenario in the file.',
258
+ ...loc,
259
+ });
260
+ }
188
261
  if (mod) {
189
262
  const missing = mod.tags.filter((t) => !sc.tags.includes(t));
190
263
  if (missing.length)
@@ -216,6 +289,19 @@ export async function lintProject(opts) {
216
289
  seen.set(key, sc.line);
217
290
  }
218
291
  }
292
+ // Role matrix (#118): the yaml, its roles and pool accounts, template placement, and staleness.
293
+ for (const p of [...matrixPlan.problems, ...matrixPlan.files.flatMap((f) => f.problems)]) {
294
+ const { severity, rule, message, file, line } = p;
295
+ (severity === 'error' ? errors : warnings).push({ severity, rule, message, file, line });
296
+ }
297
+ for (const f of matrixPlan.files.filter((x) => x.changed))
298
+ warnings.push({
299
+ severity: 'warning',
300
+ rule: 'matrix/stale',
301
+ message: `Generated Examples are out of date with ${ROLES_MATRIX_FILE}; run \`sdods matrix expand -p ${project.slug}\`.`,
302
+ file: f.path,
303
+ line: 1,
304
+ });
219
305
  // A phrasing matched by two definitions stops bddgen generating anything (#60).
220
306
  const ambiguity = await checkStepAmbiguity({ project, features: parsedFeatures });
221
307
  errors.push(...ambiguity.errors);