@sdods/cli 0.8.0 → 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.
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ export declare function register(program: Command): void;
3
+ //# sourceMappingURL=baselines.d.ts.map
@@ -0,0 +1,111 @@
1
+ import { relative, resolve as resolvePath } from 'node:path';
2
+ import pc from 'picocolors';
3
+ import { DEFAULT_ARTIFACTS_DIR, SdodsError, acceptVisualFailures, listVisualFailures, selectVisualFailures, } from '@sdods/core';
4
+ import { createContext } from '../context.js';
5
+ import { heading, json, ok, out, table } from '../ui.js';
6
+ import { listRuns } from './trace.js';
7
+ function artifactsDirOf(ctx, flag) {
8
+ return resolvePath(ctx.rootDir, flag ?? process.env.SDODS_ARTIFACTS_DIR ?? DEFAULT_ARTIFACTS_DIR);
9
+ }
10
+ function findRun(ctx, opts) {
11
+ const artifactsDir = artifactsDirOf(ctx, opts.artifactsDir);
12
+ const runs = listRuns(artifactsDir);
13
+ const run = opts.run ? runs.find((r) => r.runId === opts.run) : runs[0];
14
+ if (!run)
15
+ throw new SdodsError('CONFIG_NOT_FOUND', opts.run
16
+ ? `Run ${opts.run} not found under ${artifactsDir}.`
17
+ : `No runs under ${artifactsDir}.`, {
18
+ hint: 'Run `sdods run -t @visual …` first, or download the CI run into .sdods/runs/<id>.',
19
+ exitCode: 2,
20
+ });
21
+ return run;
22
+ }
23
+ const pct = (r) => (r === undefined ? '' : `${(r * 100).toFixed(2)}%`);
24
+ export function register(program) {
25
+ const baselines = program
26
+ .command('baselines')
27
+ .description('Review failed visual baseline checks of a run and accept the new screenshots');
28
+ baselines
29
+ .command('diff')
30
+ .description('List the visual checks that failed in a run: expected, actual and diff images')
31
+ .option('--run <id>', 'run id (default: the most recent run)')
32
+ .option('--artifacts-dir <dir>', `artifacts directory (default: ${DEFAULT_ARTIFACTS_DIR})`)
33
+ .action(async (opts, cmd) => {
34
+ const ctx = createContext(cmd);
35
+ const run = findRun(ctx, opts);
36
+ const failures = listVisualFailures(run.dir);
37
+ const abs = (p) => (p ? resolvePath(run.dir, p) : undefined);
38
+ if (ctx.opts.json)
39
+ return json({
40
+ runId: run.runId,
41
+ failures: failures.map((f) => ({
42
+ ...f,
43
+ actual: abs(f.actual),
44
+ expected: abs(f.expected),
45
+ diff: abs(f.diff),
46
+ })),
47
+ });
48
+ if (!failures.length) {
49
+ ok(`No failed visual checks in run ${run.runId}.`);
50
+ return;
51
+ }
52
+ heading(`${failures.length} failed visual check(s) in run ${run.runId}`);
53
+ const rel = (p) => (p ? relative(ctx.rootDir, p) : '—');
54
+ for (const f of failures) {
55
+ out('');
56
+ out(`${pc.bold(`${f.runnerProject}/${f.name}`)} ${pc.dim(f.platform)} ${reasonLabel(f)}`);
57
+ out(` scenario ${f.scenarioName} ${pc.dim(`(${f.featureUri})`)}`);
58
+ out(` baseline ${f.baseline}`);
59
+ table([
60
+ { image: 'expected', path: rel(abs(f.expected)) },
61
+ { image: 'actual', path: rel(abs(f.actual)) },
62
+ { image: 'diff', path: rel(abs(f.diff)) },
63
+ ]);
64
+ }
65
+ out('');
66
+ out(pc.dim(`Accept after reviewing the diff: sdods baselines accept <name…> --run ${run.runId} (or --all)`));
67
+ });
68
+ baselines
69
+ .command('accept [names...]')
70
+ .description('Copy the actual screenshot of failed visual checks over their baselines (writes to the project tree)')
71
+ .requiredOption('--run <id>', 'run id the screenshots come from (required)')
72
+ .option('--all', 'accept every failed visual check of the run')
73
+ .option('--artifacts-dir <dir>', `artifacts directory (default: ${DEFAULT_ARTIFACTS_DIR})`)
74
+ .action(async (names, opts, cmd) => {
75
+ const ctx = createContext(cmd);
76
+ if (opts.all && names.length)
77
+ throw new SdodsError('CONFIG_INVALID', 'Pass baseline names or --all, not both.', {
78
+ exitCode: 2,
79
+ });
80
+ const run = findRun(ctx, opts);
81
+ const failures = listVisualFailures(run.dir);
82
+ if (!failures.length)
83
+ throw new SdodsError('CONFIG_NOT_FOUND', `No failed visual checks in run ${run.runId}.`, {
84
+ hint: 'Nothing to accept. `sdods baselines diff --run <id>` lists what failed.',
85
+ exitCode: 2,
86
+ });
87
+ const picked = selectVisualFailures(failures, { names, all: opts.all });
88
+ const accepted = acceptVisualFailures({
89
+ runDir: run.dir,
90
+ failures: picked,
91
+ projectRoot: (slug) => ctx.registry.rootOf(slug),
92
+ });
93
+ if (ctx.opts.json)
94
+ return json({ runId: run.runId, accepted });
95
+ for (const a of accepted) {
96
+ ok(`${a.created ? 'created' : 'updated'} ${relative(ctx.rootDir, a.to)} ${pc.dim(`${a.runnerProject}/${a.name} · ${a.platform} · ${reasonLabel(a)}`)}`);
97
+ out(pc.dim(` from ${relative(ctx.rootDir, a.from)}`));
98
+ if (a.diff)
99
+ out(pc.dim(` diff ${relative(ctx.rootDir, a.diff)}`));
100
+ }
101
+ out(pc.dim('Review the PNGs with `git diff --stat` and commit them with the change that moved the pixels.'));
102
+ });
103
+ }
104
+ function reasonLabel(f) {
105
+ if (f.reason === 'missing')
106
+ return pc.cyan('new baseline');
107
+ if (f.reason === 'size')
108
+ return pc.yellow('size changed');
109
+ return pc.yellow(`changed ${pct(f.diffRatio)}`.trim());
110
+ }
111
+ //# sourceMappingURL=baselines.js.map
@@ -2,17 +2,18 @@ import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { execa } from 'execa';
4
4
  import pc from 'picocolors';
5
- import { collectVarRefs, loadDotEnvLayer, loadEnvFile } from '@sdods/core';
5
+ import { collectVarRefs, describeWorkers, effectiveWorkers, loadDotEnvLayer, loadEnvFile, poolAccountsByEnv, } from '@sdods/core';
6
6
  import { createContext } from '../context.js';
7
7
  import { browserStatuses } from './browsers.js';
8
8
  import { MIN_PLAYWRIGHT_FOR_STEP_RESULTS, installedPlaywrightVersion, stepResultsWarning, } from '../runner-compat.js';
9
- import { json, out } from '../ui.js';
9
+ import { json, out, parseIntFlag } from '../ui.js';
10
10
  export function registerDoctorCommand(program) {
11
11
  program
12
12
  .command('doctor')
13
13
  .description('Check Node, Bun, browsers, projects, env vars and database reachability')
14
14
  .option('-p, --project <slug>', 'limit env-var checks to one project')
15
15
  .option('--fix', 'install missing browsers')
16
+ .option('-w, --workers <n>', 'worker count to check user pools against (default: SDODS_WORKERS, else Playwright default)', parseIntFlag('workers'))
16
17
  .action(async (opts, cmd) => {
17
18
  const ctx = createContext(cmd);
18
19
  const checks = [];
@@ -45,6 +46,8 @@ export function registerDoctorCommand(program) {
45
46
  cwd: ctx.rootDir,
46
47
  }).catch(() => undefined);
47
48
  }
49
+ const envWorkers = Number(process.env.SDODS_WORKERS);
50
+ const workers = opts.workers ?? (Number.isInteger(envWorkers) && envWorkers > 0 ? envWorkers : undefined);
48
51
  const entries = opts.project
49
52
  ? [ctx.registry.entry(opts.project)]
50
53
  : ctx.registry.entriesList();
@@ -80,6 +83,9 @@ export function registerDoctorCommand(program) {
80
83
  ? `Add ${missing.join(', ')} to ${join(e.root, `.env.${envName}`)}`
81
84
  : undefined,
82
85
  });
86
+ const pool = poolCheck(e.slug, envName, { ...e.config, root: e.root }, envCfg.users.poolSize, workers);
87
+ if (pool)
88
+ checks.push(pool);
83
89
  }
84
90
  }
85
91
  const driver = process.env.DB_DRIVER ?? 'sqlite';
@@ -121,6 +127,42 @@ export function registerDoctorCommand(program) {
121
127
  process.exitCode = 1;
122
128
  });
123
129
  }
130
+ /**
131
+ * #153: accounts per role in an exclusive, worker-held pool against the worker count. Advisory —
132
+ * doctor does not know which roles a run will select; `sdods run` makes the real decision and
133
+ * fails with USER_POOL_TOO_SMALL. Undefined when there is nothing to compare.
134
+ */
135
+ function poolCheck(slug, envName, project, poolSize, workers) {
136
+ const pool = project.data.userPool;
137
+ if (!pool)
138
+ return undefined;
139
+ const name = `${slug}/${envName} user pool`;
140
+ if (pool.mode === 'shared' || pool.leaseScope === 'scenario') {
141
+ return {
142
+ name,
143
+ ok: true,
144
+ detail: pool.mode === 'shared' ? 'mode: shared (no leases)' : 'leaseScope: scenario (queues)',
145
+ };
146
+ }
147
+ const accounts = poolAccountsByEnv(project, {
148
+ envs: [envName],
149
+ poolSize: () => poolSize,
150
+ })?.get(envName);
151
+ if (!accounts)
152
+ return undefined; // not file-backed
153
+ const n = effectiveWorkers(workers);
154
+ const short = [...accounts].filter(([, count]) => count < n);
155
+ return {
156
+ name,
157
+ ok: short.length === 0,
158
+ optional: true,
159
+ detail: `${[...accounts].map(([role, count]) => `${role} ${count}`).join(', ') || 'no accounts'} ` +
160
+ `vs ${describeWorkers(workers)}`,
161
+ fix: short.length
162
+ ? `fewer accounts than workers for ${short.map(([r]) => r).join(', ')}: sdods run stops with USER_POOL_TOO_SMALL when more of their @user: scenarios could run at once than there are accounts. Set data.userPool.leaseScope: scenario, add accounts, or lower --workers.`
163
+ : undefined,
164
+ };
165
+ }
124
166
  /**
125
167
  * The token matrix. "one-of" rows form a group: agents need ANY one of them.
126
168
  * Platform features never need an external token.
@@ -40,6 +40,7 @@ export interface RunFlags {
40
40
  trigger?: string;
41
41
  allowEmpty?: boolean;
42
42
  notify?: boolean;
43
+ allowPoolContention?: boolean;
43
44
  }
44
45
  export declare function register(program: Command): void;
45
46
  export declare function runCommand(flags: RunFlags, cmd: Command): Promise<number>;
@@ -3,13 +3,13 @@ import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path';
3
3
  import { execa } from 'execa';
4
4
  import pc from 'picocolors';
5
5
  import { RecordingModeSchema, newRunId, runFiles, } from '@sdods/contracts';
6
- import { SdodsError, CLI_OVERRIDES_ENV, VERSION, formatFindings, lintProject, listGeneratedProjects, moduleByName, moduleDir, normalizeTagExpr, parseTagExpr, redactRunTraces, serializeCliOverrides, setupTierOf, } from '@sdods/core';
6
+ import { SdodsError, CLI_OVERRIDES_ENV, VERSION, checkPoolCapacity, collectGateEvidence, effectiveWorkers, evaluateGates, formatFindings, hasGates, lintProject, listGeneratedProjects, moduleByName, moduleDir, normalizeTagExpr, parseTagExpr, poolAccountsFor, poolDemandByRole, poolTooSmallError, redactRunTraces, serializeCliOverrides, setupTierOf, } from '@sdods/core';
7
7
  import { analyzeChangeImpact } from '@sdods/mcp';
8
8
  import { createContext } from '../context.js';
9
9
  import { browserStatuses } from './browsers.js';
10
10
  import { maybeNotify, notifyRun } from '../notify.js';
11
11
  import { installedPlaywrightVersion, stepResultsWarning } from '../runner-compat.js';
12
- import { collect, json, out, parseIntFlag, table, warn } from '../ui.js';
12
+ import { collect, json, out, parseIntFlag, renderError, table, warn } from '../ui.js';
13
13
  const RECORDING_MODES = RecordingModeSchema.options.join(' | ');
14
14
  /** `--trace`/`--video` take Playwright's modes; refuse anything else before specs are generated. */
15
15
  function recordingMode(flag, value) {
@@ -37,6 +37,7 @@ function addRunOptions(cmd) {
37
37
  .option('--device <name>', 'device name for mobile emulation, e.g. "iPhone 15"')
38
38
  .option('--headed', 'run headed')
39
39
  .option('-w, --workers <n>', 'parallel workers', parseIntFlag('workers'))
40
+ .option('--allow-pool-contention', 'run even when a @user: role has fewer pool accounts than the workers that need it')
40
41
  .option('--shard <i/n>', 'shard, e.g. 1/3')
41
42
  .option('--retries <n>', 'retries per test', parseIntFlag('retries'))
42
43
  .option('--trace <mode>', `Playwright trace: ${RECORDING_MODES} (default: evidence.trace)`)
@@ -54,7 +55,7 @@ function addRunOptions(cmd) {
54
55
  .option('--har-update', 'record/refresh HAR files for @har scenarios')
55
56
  .option('--har-replay', 'replay HAR files for @har scenarios')
56
57
  .option('--strict', 'with --har-replay: abort on any request not in the HAR (offline)')
57
- .option('--update-snapshots', 'update visual baselines')
58
+ .option('--update-snapshots', 'overwrite visual baselines without review (prefer `sdods baselines diff` / `accept`)')
58
59
  .option('--ui', 'interactive UI mode')
59
60
  .option('--debug', 'step debugger')
60
61
  .option('--list', 'list the run targets and tests that would run')
@@ -150,6 +151,9 @@ export async function runCommand(flags, cmd) {
150
151
  if (stepResults)
151
152
  warn(stepResults);
152
153
  const runId = flags.runId ?? newRunId();
154
+ if (flags.updateSnapshots)
155
+ warn('--update-snapshots overwrites every compared baseline without showing the diff. ' +
156
+ `Prefer a normal run, then \`sdods baselines diff --run ${runId}\` and \`sdods baselines accept <name> --run ${runId}\`.`);
153
157
  const cli = {
154
158
  env: envName,
155
159
  headed: flags.headed,
@@ -206,6 +210,59 @@ export async function runCommand(flags, cmd) {
206
210
  if (result.warnings.length && ctx.opts.verbose)
207
211
  process.stderr.write(formatFindings(result) + '\n');
208
212
  }
213
+ // --since selects features by git range; resolved here so the pool check counts the same selection.
214
+ const impact = flags.since
215
+ ? analyzeChangeImpact(cfg.project.root, ctx.rootDir, flags.since)
216
+ : undefined;
217
+ // #153: refuse, before generating anything, a run whose user pool cannot cover its workers.
218
+ // Listing runs nothing, and --ui / --debug run one scenario at a time by hand.
219
+ if (!flags.list && !flags.ui && !flags.debug) {
220
+ const poolCfg = cfg.project.data.userPool;
221
+ const accounts = poolCfg && poolCfg.mode !== 'shared' && poolCfg.leaseScope !== 'scenario'
222
+ ? poolAccountsFor(cfg)
223
+ : undefined;
224
+ if (accounts) {
225
+ const moduleRels = modules.map((m) => relative(cfg.project.root, moduleDir(cfg.project.root, moduleByName(projectCfg, m)))
226
+ .replace(/\\/g, '/')
227
+ .concat('/'));
228
+ const impactedRels = impact ? new Set(impact.impacted.map((r) => r.feature)) : undefined;
229
+ const grep = flags.grep ? safeRegExp(flags.grep) : undefined;
230
+ const features = cfg.env.vars?.features;
231
+ const demand = poolDemandByRole(cfg.project, cfg.env.name, {
232
+ layers: layers.length ? layers : projectCfg.layers,
233
+ browsers: browsers.length ? browsers : projectCfg.browsers,
234
+ tags,
235
+ setupTags: setup?.tags,
236
+ flags: typeof features === 'string'
237
+ ? features
238
+ .split(',')
239
+ .map((f) => f.trim())
240
+ .filter(Boolean)
241
+ : undefined,
242
+ quarantine: process.env.SDODS_QUARANTINE === 'run' ? 'run' : 'skip',
243
+ fullyParallel: proc?.fullyParallel ?? projectCfg.fullyParallel,
244
+ includeFeature: (rel) => (!featureRel || rel === featureRel) &&
245
+ (!moduleRels.length || moduleRels.some((m) => rel.startsWith(m))) &&
246
+ (!impactedRels || impactedRels.has(rel)),
247
+ includeScenario: (name) => (!flags.scenario || name.includes(flags.scenario)) &&
248
+ (!flags.grep || (grep?.test(name) ?? false)),
249
+ repeatEach: flags.repeatEach,
250
+ shardTotal: cfg.runtime.shard?.total,
251
+ });
252
+ const shortfalls = checkPoolCapacity({
253
+ pool: poolCfg,
254
+ accounts,
255
+ demand,
256
+ workers: effectiveWorkers(cfg.runtime.workers),
257
+ });
258
+ if (shortfalls.length) {
259
+ const error = poolTooSmallError(cfg, shortfalls, cfg.runtime.workers);
260
+ if (!flags.allowPoolContention)
261
+ throw error;
262
+ warn(`${error.message} Running anyway (--allow-pool-contention).`);
263
+ }
264
+ }
265
+ }
209
266
  const selection = {
210
267
  project: entry.slug,
211
268
  env: cfg.env.name,
@@ -318,8 +375,7 @@ export async function runCommand(flags, cmd) {
318
375
  // `analyzeChangeImpact` has existed for a while and was reachable only
319
376
  // through MCP, so it could advise a human and could not select a run. This is
320
377
  // the seam that was missing; the mapping itself is unchanged.
321
- if (flags.since) {
322
- const impact = analyzeChangeImpact(cfg.project.root, ctx.rootDir, flags.since);
378
+ if (flags.since && impact) {
323
379
  if (impact.impacted.length === 0) {
324
380
  // Deliberately NOT "run everything" and deliberately not a silent empty
325
381
  // run. A run that registered zero scenarios exits 0 and looks identical
@@ -389,6 +445,29 @@ export async function runCommand(flags, cmd) {
389
445
  warn(`trace redaction failed for ${relative(ctx.rootDir, e.file)}: ${e.error}`);
390
446
  }
391
447
  const summary = readSummary(runDir, runId, exitCode, Date.now() - started);
448
+ // Process gates are judged here, from the run that just finished, before anything publishes it.
449
+ // One shard of several holds only part of the run, so its pass rate and its a11y/perf evidence
450
+ // are not the run's; a sharded process is gated where the shards are merged, not per shard.
451
+ const shardTotal = cfg.runtime.shard?.total ?? 1;
452
+ let gates;
453
+ if (proc && hasGates(proc.gates) && exitCode !== 130) {
454
+ if (shardTotal > 1) {
455
+ warn(`process "${proc.name}" gates are not evaluated on shard ${cfg.runtime.shard?.current}/${shardTotal}: one shard is not the whole run.`);
456
+ }
457
+ else {
458
+ gates = evaluateGates({
459
+ process: proc.name,
460
+ gates: proc.gates,
461
+ totals: summary?.totals,
462
+ evidence: collectGateEvidence(runDir),
463
+ });
464
+ writeFileSync(join(runDir, runFiles.gates), JSON.stringify(gates, null, 2));
465
+ if (summary)
466
+ summary.gates = gates;
467
+ manifest.gatesPassed = gates.passed;
468
+ writeManifest();
469
+ }
470
+ }
392
471
  if (summary)
393
472
  writeFileSync(join(runDir, runFiles.summary), JSON.stringify(summary, null, 2));
394
473
  const dbConfigured = process.env.DB_DRIVER === 'postgres'
@@ -428,6 +507,13 @@ export async function runCommand(flags, cmd) {
428
507
  writeManifest();
429
508
  }
430
509
  }
510
+ // A breached gate fails a run whose scenarios passed. Exit 1, as the exit-code reference says:
511
+ // a gate not met is a failure, not a configuration error.
512
+ if (gates && !gates.passed && finalExit === 0) {
513
+ finalExit = 1;
514
+ manifest.exitCode = finalExit;
515
+ writeManifest();
516
+ }
431
517
  // Enabled integrations (check runs, PR comment, issues) act on the run here, the same way
432
518
  // `sdods integrations notify --run-id` does. Failures are reported and never change the exit code.
433
519
  const notify = await maybeNotify({
@@ -461,9 +547,20 @@ export async function runCommand(flags, cmd) {
461
547
  out(pc.dim(`integrations: not notified (${notify.reason})`));
462
548
  }
463
549
  if (ctx.opts.json) {
464
- json({ runId, runDir, exitCode: finalExit, summary, manifest, notify });
550
+ json({ runId, runDir, exitCode: finalExit, summary, manifest, notify, gates });
465
551
  }
466
552
  else {
553
+ if (gates) {
554
+ out('');
555
+ out(pc.bold(`gates · process ${gates.process}`));
556
+ table(gates.rows.map((r) => ({
557
+ gate: r.gate,
558
+ threshold: r.threshold,
559
+ actual: r.actual,
560
+ result: r.passed ? 'pass' : 'FAIL',
561
+ detail: r.detail ?? '',
562
+ })));
563
+ }
467
564
  const t = summary?.totals;
468
565
  out('');
469
566
  out(`${finalExit === 0 ? pc.green('✔ passed') : finalExit === 130 ? pc.yellow('■ cancelled') : finalExit === 2 ? pc.yellow('■ nothing ran') : pc.red('✖ failed')} ${t ? `${t.passed} passed, ${t.failed} failed, ${t.skipped} skipped, ${t.flaky} flaky` : ''} ${pc.dim(`(${Math.round((Date.now() - started) / 1000)}s)`)}`);
@@ -471,6 +568,13 @@ export async function runCommand(flags, cmd) {
471
568
  out(pc.dim(`html report: ${join(runDir, runFiles.htmlReport, 'index.html')}`));
472
569
  out(pc.dim(`dashboard: ${join(runDir, runFiles.dashboard, 'index.html')}`));
473
570
  }
571
+ if (gates && !gates.passed) {
572
+ const failed = gates.rows.filter((r) => !r.passed);
573
+ renderError(new SdodsError('GATE_FAILED', `Process "${gates.process}" did not meet ${failed.length} gate(s): ${failed.map((r) => `${r.gate} (${r.actual}, needs ${r.threshold})`).join('; ')}.`, {
574
+ hint: `The verdict is in ${join(runDir, runFiles.gates)}. Gates are set under processes[].gates in sdods.project.yaml.`,
575
+ docsPath: '/docs/guides/processes-and-testing-types',
576
+ }), Boolean(ctx.opts.json));
577
+ }
474
578
  return finalExit;
475
579
  }
476
580
  /**
@@ -496,6 +600,15 @@ function projectFeaturePath(projectRoot, repoRoot, input) {
496
600
  });
497
601
  return rel;
498
602
  }
603
+ /** `--grep` as the pool check reads it; a pattern Playwright would reject matches no scenario. */
604
+ function safeRegExp(pattern) {
605
+ try {
606
+ return new RegExp(pattern);
607
+ }
608
+ catch {
609
+ return undefined;
610
+ }
611
+ }
499
612
  function escapeRe(s) {
500
613
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
501
614
  }
package/dist/program.js CHANGED
@@ -24,6 +24,7 @@ import { register as registerCoverage } from './commands/coverage.js';
24
24
  import { register as registerBrowsers } from './commands/browsers.js';
25
25
  import { register as registerWatch } from './commands/watch.js';
26
26
  import { register as registerTrace } from './commands/trace.js';
27
+ import { register as registerBaselines } from './commands/baselines.js';
27
28
  import { register as registerAgent } from './commands/agent.js';
28
29
  import { register as registerProposals } from './commands/proposals.js';
29
30
  import { register as registerMcp } from './commands/mcp.js';
@@ -80,6 +81,7 @@ export function buildProgram() {
80
81
  registerBrowsers(program);
81
82
  registerWatch(program);
82
83
  registerTrace(program);
84
+ registerBaselines(program);
83
85
  registerAgent(program);
84
86
  registerProposals(program);
85
87
  registerMcp(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "SDODS command line: run, record, analyze, agents, MCP server, scheduler and the web server.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SDODS <admin@sdods.com>",
@@ -24,8 +24,8 @@
24
24
  "node": ">=22"
25
25
  },
26
26
  "dependencies": {
27
- "@sdods/contracts": "0.8.0",
28
- "@sdods/core": "0.8.0",
27
+ "@sdods/contracts": "0.9.0",
28
+ "@sdods/core": "0.9.0",
29
29
  "commander": "^15.0.0",
30
30
  "execa": "^10.0.1",
31
31
  "picocolors": "^1.1.1",
@@ -34,12 +34,12 @@
34
34
  "zod": "^4.5.4",
35
35
  "@playwright/test": "^1.63.0",
36
36
  "playwright-core": "^1.63.0",
37
- "@sdods/integrations": "0.8.0",
38
- "@sdods/mcp": "0.8.0",
39
- "@sdods/agents": "0.8.0",
40
- "@sdods/db": "0.8.0",
37
+ "@sdods/integrations": "0.9.0",
38
+ "@sdods/mcp": "0.9.0",
39
+ "@sdods/agents": "0.9.0",
40
+ "@sdods/db": "0.9.0",
41
41
  "csv-parse": "^7.0.2",
42
- "@sdods/server": "0.8.0"
42
+ "@sdods/server": "0.9.0"
43
43
  },
44
44
  "homepage": "https://sdods.com",
45
45
  "bugs": {
@@ -1,6 +1,6 @@
1
1
  @ui @auth @pool
2
2
  Feature: User pool
3
- Accounts are leased per worker from the pool dataset; login state is cached as storageState.
3
+ Accounts are leased per scenario from the pool dataset; login state is cached as storageState.
4
4
 
5
5
  @sanity @user:standard
6
6
  Scenario: A leased standard user starts logged in through cached storage state
@@ -0,0 +1,14 @@
1
+ @ui @cart @perf
2
+ Feature: Cart performance
3
+ The @perf tag records the vitals of every document the scenario loads and judges them against
4
+ perf.budgets (pageLoadMs and lcpMs from the project, pageLoadMs overridden in envs/staging.yaml).
5
+
6
+ # WebKit does not implement largest-contentful-paint, and a vital a budget applies to that never
7
+ # fired fails the scenario rather than passing it, so this runs on Chromium and Firefox only.
8
+ @regression @skip:webkit
9
+ Scenario: The login and cart pages load within their budgets
10
+ Given I am on the login page
11
+ When I login with "standard_user" and "{{standardPassword}}"
12
+ And I add "{{defaultProduct}}" to the cart
13
+ And I navigate to the "cart" page
14
+ Then the cart should list "{{defaultProduct}}"
@@ -0,0 +1,12 @@
1
+ @ui @inventory @a11y
2
+ Feature: Inventory accessibility
3
+ The @a11y tag audits the page each scenario ends on with axe (WCAG 2.x A and AA) and fails on
4
+ anything at or above a11y.failOn (serious by default). SauceDemo's login, inventory and cart
5
+ pages have no WCAG A/AA violation axe can detect, so nothing is scoped out with include/exclude.
6
+
7
+ @regression
8
+ Scenario: The inventory page has no serious accessibility violations
9
+ Given I am on the login page
10
+ When I login with "standard_user" and "{{standardPassword}}"
11
+ Then I should be on the inventory page
12
+ And there should be 6 products listed
@@ -1,6 +1,7 @@
1
1
  @ui @inventory @visual
2
2
  Feature: Inventory visual baseline
3
- Pixel comparison against a per-browser baseline (run once with --update-snapshots).
3
+ Pixel comparison against a per-browser, per-platform baseline. Review a failure with
4
+ `sdods baselines diff` and accept it with `sdods baselines accept inventory --run <id>`.
4
5
 
5
6
  @regression
6
7
  Scenario: Inventory page matches its baseline
@@ -101,6 +101,9 @@ data:
101
101
  roleColumn: role
102
102
  leaseStore: file
103
103
  leaseTtlMs: 600000
104
+ # One account per role after poolSize, and `release:check:live` runs three browsers in one
105
+ # invocation: release each account when its scenario ends so the browsers queue for it (#153).
106
+ leaseScope: scenario
104
107
 
105
108
  auth:
106
109
  strategy: form