@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/a11y/audit.d.ts +88 -0
- package/dist/a11y/audit.js +134 -0
- package/dist/analyze/index.d.ts +2 -0
- package/dist/analyze/index.js +2 -0
- package/dist/analyze/traceability-render.d.ts +5 -0
- package/dist/analyze/traceability-render.js +254 -0
- package/dist/analyze/traceability.d.ts +87 -0
- package/dist/analyze/traceability.js +559 -0
- package/dist/api/json-path.d.ts +11 -1
- package/dist/api/json-path.js +23 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/emulation.d.ts +74 -0
- package/dist/config/emulation.js +154 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/resolve.js +1 -1
- package/dist/config/runner.js +4 -1
- package/dist/config/tags.d.ts +1 -1
- package/dist/config/tags.js +7 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/pool-capacity.d.ts +58 -0
- package/dist/data/pool-capacity.js +138 -0
- package/dist/data/user-pool.d.ts +43 -2
- package/dist/data/user-pool.js +170 -40
- package/dist/errors.d.ts +1 -1
- package/dist/fixtures/api-context.d.ts +19 -1
- package/dist/fixtures/api-context.js +28 -1
- package/dist/fixtures/scenario.d.ts +11 -0
- package/dist/fixtures/scenario.js +15 -0
- package/dist/fixtures/test.js +60 -3
- package/dist/fixtures/types.d.ts +5 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lint/index.js +86 -0
- package/dist/load/index.d.ts +4 -0
- package/dist/load/index.js +4 -0
- package/dist/load/profile.d.ts +22 -0
- package/dist/load/profile.js +109 -0
- package/dist/load/run.d.ts +85 -0
- package/dist/load/run.js +255 -0
- package/dist/load/script.d.ts +27 -0
- package/dist/load/script.js +188 -0
- package/dist/mail/extract.d.ts +38 -0
- package/dist/mail/extract.js +106 -0
- package/dist/mail/index.d.ts +8 -0
- package/dist/mail/index.js +11 -0
- package/dist/mail/mailpit.d.ts +26 -0
- package/dist/mail/mailpit.js +124 -0
- package/dist/mail/types.d.ts +38 -0
- package/dist/mail/types.js +2 -0
- package/dist/matrix/index.d.ts +117 -0
- package/dist/matrix/index.js +507 -0
- package/dist/perf/vitals.d.ts +118 -0
- package/dist/perf/vitals.js +236 -0
- package/dist/quality/a11y-scenario.d.ts +68 -0
- package/dist/quality/a11y-scenario.js +63 -0
- package/dist/quality/gates.d.ts +54 -0
- package/dist/quality/gates.js +144 -0
- package/dist/quality/hooks.d.ts +2 -0
- package/dist/quality/hooks.js +38 -0
- package/dist/quality/index.d.ts +4 -0
- package/dist/quality/index.js +2 -0
- package/dist/quality/perf-scenario.d.ts +132 -0
- package/dist/quality/perf-scenario.js +241 -0
- package/dist/shots/baselines.d.ts +72 -0
- package/dist/shots/baselines.js +201 -0
- package/dist/shots/hooks.js +2 -2
- package/dist/shots/index.d.ts +1 -0
- package/dist/shots/index.js +1 -0
- package/dist/shots/narrator.d.ts +10 -2
- package/dist/shots/narrator.js +106 -10
- package/dist/shots/policy.d.ts +5 -1
- package/dist/shots/policy.js +2 -0
- package/dist/steps/a11y.steps.d.ts +18 -51
- package/dist/steps/a11y.steps.js +6 -107
- package/dist/steps/api.steps.js +5 -7
- package/dist/steps/browser.steps.d.ts +12 -0
- package/dist/steps/browser.steps.js +154 -3
- package/dist/steps/canvas.steps.d.ts +9 -0
- package/dist/steps/canvas.steps.js +331 -0
- package/dist/steps/data.steps.js +17 -5
- package/dist/steps/email.steps.d.ts +2 -0
- package/dist/steps/email.steps.js +182 -0
- package/dist/steps/hooks.steps.d.ts +1 -0
- package/dist/steps/hooks.steps.js +3 -1
- package/dist/steps/hybrid.steps.js +2 -2
- package/dist/steps/index.d.ts +3 -0
- package/dist/steps/index.js +3 -0
- package/dist/steps/perf.steps.d.ts +3 -129
- package/dist/steps/perf.steps.js +4 -227
- package/dist/steps/ui.steps.js +5 -0
- package/package.json +4 -3
package/dist/data/user-pool.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
212
|
+
const waitMs = this.opts.waitMs ?? leaseWaitMs(pool);
|
|
165
213
|
const started = Date.now();
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
index
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, '..');
|
package/dist/fixtures/test.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/fixtures/types.d.ts
CHANGED
|
@@ -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
|
package/dist/lint/index.js
CHANGED
|
@@ -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);
|