@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/commands/baselines.d.ts +3 -0
- package/dist/commands/baselines.js +111 -0
- package/dist/commands/doctor.js +44 -2
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +119 -6
- package/dist/program.js +2 -0
- package/package.json +8 -8
- package/templates/projects/demo-shop/features/__screenshots__/demo-shop--ui--chromium/linux/inventory.png +0 -0
- package/templates/projects/demo-shop/features/__screenshots__/demo-shop--ui--firefox/linux/inventory.png +0 -0
- package/templates/projects/demo-shop/features/__screenshots__/demo-shop--ui--webkit/linux/inventory.png +0 -0
- package/templates/projects/demo-shop/features/auth/user-pool.feature +1 -1
- package/templates/projects/demo-shop/features/cart/performance.feature +14 -0
- package/templates/projects/demo-shop/features/inventory/accessibility.feature +12 -0
- package/templates/projects/demo-shop/features/inventory/visual.feature +2 -1
- package/templates/projects/demo-shop/sdods.project.yaml +3 -0
|
@@ -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
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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.
|
package/dist/commands/run.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/run.js
CHANGED
|
@@ -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', '
|
|
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.
|
|
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.
|
|
28
|
-
"@sdods/core": "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.
|
|
38
|
-
"@sdods/mcp": "0.
|
|
39
|
-
"@sdods/agents": "0.
|
|
40
|
-
"@sdods/db": "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.
|
|
42
|
+
"@sdods/server": "0.9.0"
|
|
43
43
|
},
|
|
44
44
|
"homepage": "https://sdods.com",
|
|
45
45
|
"bugs": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
@ui @auth @pool
|
|
2
2
|
Feature: User pool
|
|
3
|
-
Accounts are leased per
|
|
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
|
|
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
|