@sdods/cli 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.
@@ -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';
@@ -36,6 +37,8 @@ import { register as registerIntegrations } from './commands/integrations.js';
36
37
  import { register as registerCompletion } from './commands/completion.js';
37
38
  import { register as registerUpgrade } from './commands/upgrade.js';
38
39
  import { register as registerFeedback } from './commands/feedback.js';
40
+ import { register as registerLoad } from './commands/load.js';
41
+ import { register as registerMatrix } from './commands/matrix.js';
39
42
  /**
40
43
  * All commands are registered eagerly (cheap) but each module keeps heavy imports
41
44
  * (server, agents, DB, browser runner) inside its action via dynamic import.
@@ -78,6 +81,7 @@ export function buildProgram() {
78
81
  registerBrowsers(program);
79
82
  registerWatch(program);
80
83
  registerTrace(program);
84
+ registerBaselines(program);
81
85
  registerAgent(program);
82
86
  registerProposals(program);
83
87
  registerMcp(program);
@@ -90,6 +94,8 @@ export function buildProgram() {
90
94
  registerCompletion(program);
91
95
  registerUpgrade(program);
92
96
  registerFeedback(program);
97
+ registerLoad(program);
98
+ registerMatrix(program);
93
99
  return program;
94
100
  }
95
101
  //# sourceMappingURL=program.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.7.2",
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.7.2",
28
- "@sdods/core": "0.7.2",
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.7.2",
38
- "@sdods/mcp": "0.7.2",
39
- "@sdods/agents": "0.7.2",
40
- "@sdods/db": "0.7.2",
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.7.2"
42
+ "@sdods/server": "0.9.0"
43
43
  },
44
44
  "homepage": "https://sdods.com",
45
45
  "bugs": {
@@ -18,7 +18,7 @@ over shelling out.
18
18
 
19
19
  - Every scenario carries exactly one layer tag (`@ui`, `@api`, `@hybrid`) and exactly one suite tag
20
20
  (`@smoke`, `@regression`, `@sanity`). Optional: `@visual @a11y @perf @mock @data-driven @pool`,
21
- and value tags `@user:<role> @data:<dataset> @har:<name> @jira:KEY @skip:<browser>`.
21
+ and value tags `@user:<role> @data:<dataset> @har:<name> @jira:KEY @skip:<browser> @matrix:<name> @req:<id>`, and per-scenario emulation `@locale:<bcp47> @theme:<light|dark|no-preference> @timezone:<IANA> @viewport:<W>x<H> @device:<name>`.
22
22
  - Reuse existing steps first (`sdods steps list -p <slug>`, or `step_find`). Add a step only when no
23
23
  phrasing fits, and name it in the same style.
24
24
  - Locator priority: role + name > label > test id > placeholder > text > CSS. Never XPath.
@@ -37,6 +37,7 @@ over shelling out.
37
37
  | Projects and hierarchy | `sdods project list`, `sdods workspace tree` |
38
38
  | Resolved config, with where each value came from | `sdods config show -p <slug> -e <env> --explain` |
39
39
  | Validate features | `sdods lint -p <slug>` |
40
+ | Role × surface matrix (`roles.matrix.yaml`) | `sdods matrix expand -p <slug>` · `--check` in CI |
40
41
  | Run a slice | `sdods run -p <slug> -e <env> -l api` · `-l ui -b chromium -t @smoke` · `--process pr-check` |
41
42
  | Results | `sdods report --last`, `sdods heal report --last`, `sdods trace --last` |
42
43
  | Available steps | `sdods steps list -p <slug>` |
@@ -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
@@ -0,0 +1,17 @@
1
+ # Read-only load profile for `sdods load`. The staging environment points at a public sandbox
2
+ # (JSONPlaceholder) and deliberately does NOT opt in to load, so only `--dry-run` works against it.
3
+ description: Read posts and one post's comments
4
+ thinkTimeSeconds: 1
5
+ vus: 2
6
+ duration: 20s
7
+ thresholds:
8
+ http_req_duration: ['p(95)<1500']
9
+ http_req_failed: ['rate<0.01']
10
+ checks: ['rate>0.99']
11
+ requests:
12
+ - name: list posts
13
+ path: /posts
14
+ expect: { status: 200, bodyContains: userId }
15
+ - name: post comments
16
+ path: /posts/1/comments
17
+ expect: { status: 200 }
@@ -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