@sdods/cli 0.3.2 → 0.5.2

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.
@@ -1,13 +1,17 @@
1
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { stringify as toYaml } from 'yaml';
1
+ import { spawnSync } from 'node:child_process';
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, relative, resolve } from 'node:path';
5
+ import { parseDocument, stringify as toYaml } from 'yaml';
4
6
  import { BrowserSchema, LayerSchema, ProjectConfigSchema, SlugSchema } from '@sdods/contracts';
5
- import { SdodsError, PROJECT_FILE } from '@sdods/core';
7
+ import { ProjectRegistry, SdodsError, PROJECT_FILE } from '@sdods/core';
6
8
  import { createContext } from '../context.js';
7
- import { collect, json, ok, table } from '../ui.js';
9
+ import { collect, json, ok, out, table } from '../ui.js';
8
10
  import { projectTemplateFiles } from '../templates/project.js';
9
11
  export function registerProjectCommands(program) {
10
- const project = program.command('project').description('Create and list SDODS projects');
12
+ const project = program
13
+ .command('project')
14
+ .description('Create, import, list and delete SDODS projects');
11
15
  project
12
16
  .command('list')
13
17
  .description('List projects discovered under projects/')
@@ -51,6 +55,7 @@ export function registerProjectCommands(program) {
51
55
  .command('create <slug>')
52
56
  .description('Scaffold a project: yaml, envs, fixtures, an auth stub, a page object, health features and data')
53
57
  .option('--name <name>', 'display name')
58
+ .option('--description <text>', 'one-line description')
54
59
  .option('--layers <list>', 'comma list of ui,api,hybrid,recorded', collect, [])
55
60
  .option('--browsers <list>', 'comma list of browsers', collect, [])
56
61
  .option('--ui-url <url>', 'UI base URL for the first environment', 'http://localhost:3000')
@@ -73,6 +78,7 @@ export function registerProjectCommands(program) {
73
78
  const config = ProjectConfigSchema.parse({
74
79
  slug,
75
80
  name: opts.name ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
81
+ ...(opts.description ? { description: opts.description } : {}),
76
82
  layers,
77
83
  browsers,
78
84
  testIdAttribute: opts.testId,
@@ -104,12 +110,126 @@ export function registerProjectCommands(program) {
104
110
  console.log(` ${f}`);
105
111
  console.log(`\nNext: sdods run -p ${slug} -e ${opts.env} -l api`);
106
112
  });
113
+ project
114
+ .command('delete <slug>')
115
+ .description('Move a project out of projects/ into .sdods/trash so it can be restored by hand')
116
+ .option('--yes', 'confirm the delete (required: there is no interactive prompt)')
117
+ .option('--purge', 'delete the directory outright instead of trashing it')
118
+ .action((slug, opts, cmd) => {
119
+ const ctx = createContext(cmd);
120
+ const entry = requireProject(ctx, slug);
121
+ if (!opts.yes) {
122
+ throw new SdodsError('CONFIG_INVALID', `Refusing to ${opts.purge ? 'purge' : 'delete'} "${slug}" without confirmation.`, {
123
+ hint: `Re-run with --yes. The project directory is ${entry.root}.`,
124
+ exitCode: 2,
125
+ });
126
+ }
127
+ if (opts.purge) {
128
+ rmSync(entry.root, { recursive: true, force: true });
129
+ if (ctx.opts.json)
130
+ return json({ slug, from: entry.root, to: null, purged: true });
131
+ ok(`Purged project ${slug} (${entry.root})`);
132
+ return;
133
+ }
134
+ const to = join(trashDir(ctx.rootDir), `${slug}-${timestamp()}`);
135
+ mkdirSync(join(to, '..'), { recursive: true });
136
+ move(entry.root, to);
137
+ if (ctx.opts.json)
138
+ return json({ slug, from: entry.root, to, purged: false });
139
+ ok(`Deleted project ${slug}`);
140
+ out(` moved to ${to}`);
141
+ out(` restore with: mv ${JSON.stringify(to)} ${JSON.stringify(entry.root)}`);
142
+ });
143
+ project
144
+ .command('import <source>')
145
+ .description('Register an existing SDODS project from a directory, a .zip archive or a git repository')
146
+ .option('--slug <slug>', 'import under a different slug (use on a collision)')
147
+ .option('--workspace <slug>', 'workspace to re-home the project into')
148
+ .option('--force', 'overwrite an existing project with the same slug')
149
+ .option('--dry-run', 'validate and report what would be imported, without writing')
150
+ .action(async (source, opts, cmd) => {
151
+ const ctx = createContext(cmd);
152
+ const staged = await stageSource(source);
153
+ try {
154
+ const srcDir = findProjectDir(staged.dir, source);
155
+ const config = readProjectConfig(srcDir);
156
+ const slug = opts.slug ? SlugSchema.parse(opts.slug) : config.slug;
157
+ const workspace = opts.workspace
158
+ ? SlugSchema.parse(opts.workspace)
159
+ : ctx.registry.workspaceFile.defaultWorkspace;
160
+ assertWorkspaceDeclared(ctx, workspace);
161
+ const target = join(ctx.registry.projectsDir, slug);
162
+ const collides = existsSync(join(target, PROJECT_FILE));
163
+ if (collides && !opts.force) {
164
+ throw new SdodsError('CONFIG_INVALID', `Project "${slug}" already exists at ${target}.`, {
165
+ hint: 'Pass --slug <other> to import alongside it, or --force to overwrite.',
166
+ exitCode: 2,
167
+ });
168
+ }
169
+ const report = {
170
+ slug,
171
+ name: config.name,
172
+ description: config.description ?? null,
173
+ layers: config.layers,
174
+ browsers: config.browsers,
175
+ envs: config.envs.available,
176
+ workspace,
177
+ organization: ctx.registry.workspaceFile.organization.slug,
178
+ source: staged.kind,
179
+ files: countFiles(srcDir),
180
+ root: target,
181
+ collides,
182
+ };
183
+ if (opts.dryRun) {
184
+ if (ctx.opts.json)
185
+ return json({ ...report, dryRun: true });
186
+ ok(`Would import ${slug} (${report.files} files) into ${target}`);
187
+ out(` layers ${report.layers.join(',')} · envs ${report.envs.join(',')}`);
188
+ return;
189
+ }
190
+ // Roll back wholesale on failure: a half-copied project with a bad workspace makes
191
+ // `discover` throw for *every* project, not just this one.
192
+ const backup = collides ? join(trashDir(ctx.rootDir), `${slug}-${timestamp()}`) : null;
193
+ if (backup) {
194
+ mkdirSync(join(backup, '..'), { recursive: true });
195
+ move(target, backup);
196
+ }
197
+ try {
198
+ cpSync(srcDir, target, { recursive: true, filter: importFilter(srcDir) });
199
+ rehome(join(target, PROJECT_FILE), {
200
+ slug,
201
+ workspace,
202
+ organization: ctx.registry.workspaceFile.organization.slug,
203
+ });
204
+ // Prove the whole registry still loads before we claim success.
205
+ ProjectRegistry.discover(ctx.rootDir);
206
+ }
207
+ catch (e) {
208
+ rmSync(target, { recursive: true, force: true });
209
+ if (backup)
210
+ move(backup, target);
211
+ throw e;
212
+ }
213
+ if (ctx.opts.json)
214
+ return json(report);
215
+ ok(`Imported project ${slug} into ${target}`);
216
+ out(` ${report.files} files · layers ${report.layers.join(',')}`);
217
+ out(` re-homed to organization ${report.organization} / workspace ${workspace}`);
218
+ if (backup)
219
+ out(` the project it replaced was moved to ${backup}`);
220
+ out(`\nNext: sdods lint -p ${slug}`);
221
+ }
222
+ finally {
223
+ staged.cleanup();
224
+ }
225
+ });
107
226
  }
108
227
  /** Keep the generated yaml readable: drop defaults the schema fills in. */
109
228
  function minimalYaml(config) {
110
229
  return {
111
230
  slug: config.slug,
112
231
  name: config.name,
232
+ ...(config.description ? { description: config.description } : {}),
113
233
  layers: config.layers,
114
234
  browsers: config.browsers,
115
235
  testIdAttribute: config.testIdAttribute,
@@ -130,4 +250,200 @@ function minimalYaml(config) {
130
250
  retries: { ci: 2, local: 0 },
131
251
  };
132
252
  }
253
+ function requireProject(ctx, slug) {
254
+ if (!ctx.registry.has(slug)) {
255
+ const known = ctx.registry.entriesList().map((e) => e.slug);
256
+ throw new SdodsError('PROJECT_NOT_FOUND', `No project "${slug}" under ${ctx.registry.projectsDir}.`, {
257
+ hint: known.length
258
+ ? `Known projects: ${known.join(', ')}.`
259
+ : 'This workspace has no projects.',
260
+ });
261
+ }
262
+ return ctx.registry.entry(slug);
263
+ }
264
+ /**
265
+ * Trash lives outside `projectsDir` on purpose: `ProjectRegistry.discover` walks every directory
266
+ * there looking for a project file, so a trash folder nested inside it would keep resurfacing.
267
+ * `.sdods/` is already gitignored and already holds run artifacts.
268
+ */
269
+ function trashDir(rootDir) {
270
+ return join(rootDir, '.sdods', 'trash');
271
+ }
272
+ function timestamp() {
273
+ return new Date()
274
+ .toISOString()
275
+ .replace(/[-:]/g, '')
276
+ .replace(/\.\d+Z$/, 'Z');
277
+ }
278
+ /** rename, falling back to copy+remove when the two paths live on different filesystems. */
279
+ function move(from, to) {
280
+ try {
281
+ renameSync(from, to);
282
+ }
283
+ catch (e) {
284
+ if (e.code !== 'EXDEV')
285
+ throw e;
286
+ cpSync(from, to, { recursive: true });
287
+ rmSync(from, { recursive: true, force: true });
288
+ }
289
+ }
290
+ const EXCLUDED = /(^|\/)(\.auth|node_modules|\.features-gen|\.git)(\/|$)/;
291
+ /** `.env.example` is the scaffold's own template, not a secret; every generated project has one. */
292
+ const SECRET_ENV = /(^|\/)\.env(\.(?!example)[^/]*)?$/;
293
+ function importFilter(srcDir) {
294
+ return (p) => {
295
+ const rel = relative(srcDir, p).replace(/\\/g, '/');
296
+ if (!rel)
297
+ return true;
298
+ return !EXCLUDED.test(rel) && !SECRET_ENV.test(rel);
299
+ };
300
+ }
301
+ function countFiles(dir) {
302
+ const keep = importFilter(dir);
303
+ let n = 0;
304
+ const walk = (d) => {
305
+ for (const name of readdirSync(d)) {
306
+ const abs = join(d, name);
307
+ if (!keep(abs))
308
+ continue;
309
+ if (statSync(abs).isDirectory())
310
+ walk(abs);
311
+ else
312
+ n++;
313
+ }
314
+ };
315
+ walk(dir);
316
+ return n;
317
+ }
318
+ function sourceKind(source) {
319
+ if (/^(https?|git|ssh):\/\//.test(source) || /^git@/.test(source) || source.endsWith('.git'))
320
+ return 'git';
321
+ if (/\.zip$/i.test(source))
322
+ return 'zip';
323
+ return 'directory';
324
+ }
325
+ /** Materialise the source into a directory we can read, cloning or extracting when needed. */
326
+ async function stageSource(source) {
327
+ const kind = sourceKind(source);
328
+ if (kind === 'directory') {
329
+ const dir = resolve(source);
330
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
331
+ throw new SdodsError('CONFIG_NOT_FOUND', `No such directory: ${dir}.`, {
332
+ hint: 'Pass a project directory, a .zip archive or a git URL.',
333
+ });
334
+ return { kind, dir, cleanup: () => { } };
335
+ }
336
+ const temp = mkdtempSync(join(tmpdir(), 'sdods-import-'));
337
+ const cleanup = () => rmSync(temp, { recursive: true, force: true });
338
+ try {
339
+ if (kind === 'git') {
340
+ run('git', ['clone', '--depth', '1', source, temp], {
341
+ missing: 'git is not on PATH, so a git URL cannot be cloned.',
342
+ hint: 'Clone it yourself and import the directory instead.',
343
+ });
344
+ }
345
+ else {
346
+ const zip = resolve(source);
347
+ if (!existsSync(zip))
348
+ throw new SdodsError('CONFIG_NOT_FOUND', `No such archive: ${zip}.`, {});
349
+ extractZip(zip, temp);
350
+ }
351
+ return { kind, dir: temp, cleanup };
352
+ }
353
+ catch (e) {
354
+ cleanup();
355
+ throw e;
356
+ }
357
+ }
358
+ /**
359
+ * No zip dependency: bsdtar (macOS, Windows 10+) reads zip archives directly, and `unzip` covers
360
+ * the Linux distros where tar is GNU tar and cannot.
361
+ */
362
+ function extractZip(zip, into) {
363
+ const viaTar = spawnSync('tar', ['-xf', zip, '-C', into], { stdio: 'ignore' });
364
+ if (viaTar.status === 0)
365
+ return;
366
+ const viaUnzip = spawnSync('unzip', ['-q', zip, '-d', into], { stdio: 'ignore' });
367
+ if (viaUnzip.status === 0)
368
+ return;
369
+ throw new SdodsError('NOT_SUPPORTED', `Could not extract ${zip}.`, {
370
+ hint: 'Neither `tar -xf` nor `unzip` could read it. Extract it yourself and import the directory.',
371
+ });
372
+ }
373
+ function run(cmd, args, msg) {
374
+ const res = spawnSync(cmd, args, { stdio: 'ignore' });
375
+ if (res.error && res.error.code === 'ENOENT')
376
+ throw new SdodsError('NOT_SUPPORTED', msg.missing, { hint: msg.hint });
377
+ if (res.status !== 0)
378
+ throw new SdodsError('CONFIG_INVALID', `${cmd} ${args[0]} failed (exit ${res.status}).`, {
379
+ hint: msg.hint,
380
+ });
381
+ }
382
+ /**
383
+ * Accept either a project directory or a repository that contains one, so importing a colleague's
384
+ * checkout works without asking them where the yaml is.
385
+ */
386
+ function findProjectDir(dir, source) {
387
+ if (existsSync(join(dir, PROJECT_FILE)))
388
+ return dir;
389
+ const nested = [];
390
+ const scan = (d, depth) => {
391
+ for (const name of readdirSync(d)) {
392
+ const abs = join(d, name);
393
+ if (!statSync(abs).isDirectory() || EXCLUDED.test(name))
394
+ continue;
395
+ if (existsSync(join(abs, PROJECT_FILE)))
396
+ nested.push(abs);
397
+ else if (depth > 0)
398
+ scan(abs, depth - 1);
399
+ }
400
+ };
401
+ scan(dir, 1);
402
+ if (nested.length === 1)
403
+ return nested[0];
404
+ if (nested.length > 1) {
405
+ throw new SdodsError('CONFIG_INVALID', `${source} contains ${nested.length} projects.`, {
406
+ hint: `Import them one at a time: ${nested.map((d) => relative(dir, d)).join(', ')}.`,
407
+ exitCode: 2,
408
+ });
409
+ }
410
+ throw new SdodsError('CONFIG_NOT_FOUND', `No ${PROJECT_FILE} found in ${source}.`, {
411
+ hint: `Expected it at the root, or one level down (as in projects/<slug>/${PROJECT_FILE}).`,
412
+ });
413
+ }
414
+ function readProjectConfig(dir) {
415
+ const file = join(dir, PROJECT_FILE);
416
+ const doc = parseDocument(readFileSync(file, 'utf8'));
417
+ const parsed = ProjectConfigSchema.safeParse(doc.toJS());
418
+ if (!parsed.success) {
419
+ throw new SdodsError('CONFIG_INVALID', `${file} is not a valid SDODS project.`, {
420
+ hint: parsed.error.issues
421
+ .slice(0, 3)
422
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
423
+ .join('; '),
424
+ exitCode: 2,
425
+ });
426
+ }
427
+ return parsed.data;
428
+ }
429
+ function assertWorkspaceDeclared(ctx, workspace) {
430
+ const declared = ctx.registry.workspaceFile.workspaces.map((w) => w.slug);
431
+ if (declared.includes(workspace))
432
+ return;
433
+ throw new SdodsError('CONFIG_INVALID', `Workspace "${workspace}" is not declared in the workspace file.`, {
434
+ hint: `Declared workspaces: ${declared.join(', ')}. Importing into an undeclared workspace would make every project fail to load.`,
435
+ exitCode: 2,
436
+ });
437
+ }
438
+ /**
439
+ * Re-home the imported yaml, comment-preserving. Without this the project keeps the organization
440
+ * and workspace of wherever it came from, and `discover` then rejects the *whole* registry.
441
+ */
442
+ function rehome(file, to) {
443
+ const doc = parseDocument(readFileSync(file, 'utf8'));
444
+ doc.set('slug', to.slug);
445
+ doc.set('organization', to.organization);
446
+ doc.set('workspace', to.workspace);
447
+ writeFileSync(file, doc.toString({ lineWidth: 100 }));
448
+ }
133
449
  //# sourceMappingURL=project.js.map
@@ -11,6 +11,8 @@ export interface RunFlags {
11
11
  workers?: number;
12
12
  shard?: string;
13
13
  retries?: number;
14
+ trace?: string;
15
+ video?: string;
14
16
  grep?: string;
15
17
  feature?: string;
16
18
  since?: string;
@@ -36,6 +38,8 @@ export interface RunFlags {
36
38
  device?: string;
37
39
  reporterMode?: string;
38
40
  trigger?: string;
41
+ allowEmpty?: boolean;
42
+ notify?: boolean;
39
43
  }
40
44
  export declare function register(program: Command): void;
41
45
  export declare function runCommand(flags: RunFlags, cmd: Command): Promise<number>;
@@ -1,19 +1,36 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
- import { join, relative, resolve as resolvePath } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path';
3
3
  import { execa } from 'execa';
4
4
  import pc from 'picocolors';
5
- import { newRunId, runFiles, } from '@sdods/contracts';
6
- import { SdodsError, CLI_OVERRIDES_ENV, VERSION, formatFindings, lintProject, listGeneratedProjects, moduleByName, moduleDir, normalizeTagExpr, serializeCliOverrides, } from '@sdods/core';
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';
7
7
  import { analyzeChangeImpact } from '@sdods/mcp';
8
8
  import { createContext } from '../context.js';
9
- import { collect, json, out, parseIntFlag, warn } from '../ui.js';
9
+ import { browserStatuses } from './browsers.js';
10
+ import { maybeNotify, notifyRun } from '../notify.js';
11
+ import { installedPlaywrightVersion, stepResultsWarning } from '../runner-compat.js';
12
+ import { collect, json, out, parseIntFlag, table, warn } from '../ui.js';
13
+ const RECORDING_MODES = RecordingModeSchema.options.join(' | ');
14
+ /** `--trace`/`--video` take Playwright's modes; refuse anything else before specs are generated. */
15
+ function recordingMode(flag, value) {
16
+ if (value === undefined)
17
+ return undefined;
18
+ const parsed = RecordingModeSchema.safeParse(value);
19
+ if (!parsed.success) {
20
+ throw new SdodsError('CONFIG_INVALID', `--${flag} "${value}" is not a Playwright ${flag} mode.`, {
21
+ hint: `Use one of: ${RECORDING_MODES}.`,
22
+ exitCode: 2,
23
+ });
24
+ }
25
+ return parsed.data;
26
+ }
10
27
  function addRunOptions(cmd) {
11
28
  return cmd
12
29
  .option('-p, --project <slug>', 'project slug (default: the only project, else required)')
13
30
  .option('-e, --env <name>', 'environment name')
14
31
  .option('-t, --tags <expr>', 'Cucumber tag expression, e.g. "@smoke and not @mock" (comma list = OR)')
15
32
  .option('-l, --layer <layer>', 'ui | api | hybrid | recorded (repeatable)', collect, [])
16
- .option('-b, --browser <name>', 'chromium | firefox | webkit | mobile-chrome | mobile-safari (repeatable)', collect, [])
33
+ .option('-b, --browser <name>', 'chromium | edge | firefox | webkit | mobile-chrome | mobile-safari (repeatable)', collect, [])
17
34
  .option('-m, --module <name>', 'restrict to a module (repeatable)', collect, [])
18
35
  .option('--process <name>', 'run a named process (recipe) from the project or workspace')
19
36
  .option('--project-matrix', 'run every browser declared in the project yaml')
@@ -22,6 +39,8 @@ function addRunOptions(cmd) {
22
39
  .option('-w, --workers <n>', 'parallel workers', parseIntFlag('workers'))
23
40
  .option('--shard <i/n>', 'shard, e.g. 1/3')
24
41
  .option('--retries <n>', 'retries per test', parseIntFlag('retries'))
42
+ .option('--trace <mode>', `Playwright trace: ${RECORDING_MODES} (default: evidence.trace)`)
43
+ .option('--video <mode>', `Playwright video: ${RECORDING_MODES} (default: evidence.video)`)
25
44
  .option('--grep <pattern>', 'filter tests by title (regular expression)')
26
45
  .option('--feature <path>', 'only this feature file (relative to features/)')
27
46
  .option('--since <range>', 'only features impacted by a git range, e.g. main..HEAD (test-impact analysis)')
@@ -45,7 +64,9 @@ function addRunOptions(cmd) {
45
64
  .option('--timeout <ms>', 'per-test timeout override', parseIntFlag('timeout'))
46
65
  .option('--reporter <name>', 'reporter override (repeatable, name=outputFile)', collect, [])
47
66
  .option('--reporter-mode <mode>', 'default | server | quiet')
48
- .option('--trigger <kind>', 'cli | ui | ci | agent | mcp | schedule', 'cli');
67
+ .option('--trigger <kind>', 'cli | ui | ci | agent | mcp | schedule', 'cli')
68
+ .option('--allow-empty', 'exit 0 when the selection matches no scenario (by default that is exit 2)')
69
+ .option('--no-notify', 'do not publish the run to enabled integrations (GitHub, Jira) after it finishes');
49
70
  }
50
71
  export function register(program) {
51
72
  const run = addRunOptions(program
@@ -67,6 +88,13 @@ export async function runCommand(flags, cmd) {
67
88
  const proc = flags.process ? ctx.registry.processOf(entry.slug, flags.process) : undefined;
68
89
  const envName = flags.env ?? proc?.env;
69
90
  const tags = normalizeTagExpr(flags.tags ?? proc?.tags);
91
+ // Fail on a malformed expression here, as a config error, rather than inside bddgen.
92
+ if (tags)
93
+ parseTagExpr(tags);
94
+ // Same for the setup tier the runner config will generate (`setup.tags`, or the process's own).
95
+ const setup = setupTierOf(projectCfg, proc);
96
+ if (setup)
97
+ parseTagExpr(setup.tags);
70
98
  const layers = (flags.layer.length ? flags.layer : (proc?.layers ?? []));
71
99
  const browsers = (flags.projectMatrix
72
100
  ? projectCfg.browsers
@@ -84,6 +112,8 @@ export async function runCommand(flags, cmd) {
84
112
  warn('Recording HAR fixtures runs with a single worker so shared files keep every request.');
85
113
  }
86
114
  const failOnFlaky = flags.failOnFlaky ?? proc?.failOnFlaky ?? false;
115
+ const trace = recordingMode('trace', flags.trace);
116
+ const video = recordingMode('video', flags.video);
87
117
  for (const l of layers) {
88
118
  if (!projectCfg.layers.includes(l)) {
89
119
  throw new SdodsError('CONFIG_INVALID', `Layer "${l}" is not enabled for project ${entry.slug} (layers: ${projectCfg.layers.join(', ')}).`, { exitCode: 2 });
@@ -96,6 +126,29 @@ export async function runCommand(flags, cmd) {
96
126
  }
97
127
  for (const m of modules)
98
128
  moduleByName(projectCfg, m);
129
+ // Stop before generating specs when a CHANNEL browser is missing. Playwright's own error for a
130
+ // missing engine is already actionable ("Run npx playwright install"), but for a channel it
131
+ // reports a name the user never typed and no way forward — and unlike an engine, the fix is a
132
+ // system install rather than a download.
133
+ //
134
+ // Deliberately narrow. Selecting a browser is not the same as using one: `--project-matrix -l api`
135
+ // fills `browsers` from the project yaml for a run that never opens a browser, so checking every
136
+ // selected browser here would fail runs that would have worked.
137
+ const usesBrowser = !layers.length || layers.some((l) => l !== 'api');
138
+ const missing = usesBrowser
139
+ ? (await browserStatuses(browsers)).filter((s) => s.channel && !s.installed)
140
+ : [];
141
+ if (missing.length) {
142
+ const names = missing.map((s) => s.name);
143
+ throw new SdodsError('NOT_SUPPORTED', `${names.join(', ')} ${names.length > 1 ? 'are' : 'is'} not installed.`, {
144
+ hint: `Run \`sdods browsers install ${names.map((n) => `-b ${n}`).join(' ')}\`. ` +
145
+ `${names.length > 1 ? 'They are system browsers' : 'It is a system browser'}: Playwright runs the vendor installer rather than downloading a build.`,
146
+ exitCode: 2,
147
+ });
148
+ }
149
+ const stepResults = stepResultsWarning(installedPlaywrightVersion(ctx.rootDir));
150
+ if (stepResults)
151
+ warn(stepResults);
99
152
  const runId = flags.runId ?? newRunId();
100
153
  const cli = {
101
154
  env: envName,
@@ -108,9 +161,14 @@ export async function runCommand(flags, cmd) {
108
161
  harMode: harMode,
109
162
  offline: flags.strict && harMode === 'replay' ? true : undefined,
110
163
  updateSnapshots: flags.updateSnapshots,
164
+ trace,
165
+ video,
111
166
  };
112
167
  const cfg = ctx.registry.resolve(entry.slug, envName, cli);
113
168
  const runDir = cfg.runtime.runDir;
169
+ const featureRel = flags.feature
170
+ ? projectFeaturePath(cfg.project.root, ctx.rootDir, flags.feature)
171
+ : undefined;
114
172
  mkdirSync(runDir, { recursive: true });
115
173
  const manifest = {
116
174
  runId,
@@ -159,6 +217,7 @@ export async function runCommand(flags, cmd) {
159
217
  reporters: flags.reporter.length ? flags.reporter : undefined,
160
218
  reporterMode: flags.reporterMode ??
161
219
  (ctx.opts.quiet ? 'quiet' : 'default'),
220
+ process: proc?.name,
162
221
  };
163
222
  const childEnv = {
164
223
  ...process.env,
@@ -252,8 +311,8 @@ export async function runCommand(flags, cmd) {
252
311
  filters.push(`\\.sdods/generated/${escapeRe(runId)}/${escapeRe(entry.slug)}/[^/]+/${escapeRe(rel)}/`);
253
312
  }
254
313
  const featureFilter = (rel) => escapeRe(rel.replace(/^features\//, '').replace(/\.feature$/, '')) + '\\.feature\\.spec';
255
- if (flags.feature)
256
- filters.push(featureFilter(flags.feature));
314
+ if (featureRel)
315
+ filters.push(featureFilter(featureRel));
257
316
  // --since <range>: run only what the change could have broken.
258
317
  //
259
318
  // `analyzeChangeImpact` has existed for a while and was reachable only
@@ -283,8 +342,12 @@ export async function runCommand(flags, cmd) {
283
342
  args.splice(4, 0, ...filters);
284
343
  if (flags.list) {
285
344
  out(pc.bold('Run targets:'));
286
- for (const p of runnerProjects)
345
+ for (const p of runnerProjects) {
346
+ // Playwright runs the setup companion as a dependency; list it so the targets match the tests.
347
+ if (setup && p.layer !== 'recorded')
348
+ out(` ${p.name}--setup`);
287
349
  out(` ${p.name}`);
350
+ }
288
351
  const listed = await execa('npx', args, {
289
352
  cwd: ctx.rootDir,
290
353
  env: childEnv,
@@ -315,6 +378,16 @@ export async function runCommand(flags, cmd) {
315
378
  manifest.finishedAt = new Date().toISOString();
316
379
  manifest.exitCode = exitCode;
317
380
  writeManifest();
381
+ // Traces carry the session of the account that ran each test (cookies, Authorization headers,
382
+ // localStorage/IndexedDB tokens). Redact every copy before anything reads the run directory:
383
+ // ingest, integrations, and the CI step that uploads it as an artifact (#101).
384
+ if (cfg.project.evidence.redactTraces !== false) {
385
+ const redaction = redactRunTraces(runDir);
386
+ if (redaction.files.length)
387
+ out(pc.dim(`traces: redacted ${redaction.values} credential value(s) in ${redaction.files.length} file(s)`));
388
+ for (const e of redaction.errors)
389
+ warn(`trace redaction failed for ${relative(ctx.rootDir, e.file)}: ${e.error}`);
390
+ }
318
391
  const summary = readSummary(runDir, runId, exitCode, Date.now() - started);
319
392
  if (summary)
320
393
  writeFileSync(join(runDir, runFiles.summary), JSON.stringify(summary, null, 2));
@@ -340,20 +413,88 @@ export async function runCommand(flags, cmd) {
340
413
  warn(`ingest skipped: ${e.message}`);
341
414
  }
342
415
  }
343
- if (summary && summary.totals.total === 0)
344
- warn(`No scenarios matched the selection${tags ? ` (tags: ${tags})` : ''}.`);
416
+ // Zero scenarios with exit 0 is indistinguishable from a green run, so a typo in --tags or
417
+ // --feature used to keep CI passing while testing nothing. One shard of several can
418
+ // legitimately receive nothing, and --allow-empty opts out explicitly.
419
+ let finalExit = exitCode;
420
+ if (summary && summary.totals.total === 0) {
421
+ const emptyShard = (cfg.runtime.shard?.total ?? 1) > 1;
422
+ const message = `No scenarios matched the selection${tags ? ` (tags: ${tags})` : ''}${featureRel ? ` (feature: ${featureRel})` : ''}.`;
423
+ warn(message);
424
+ if (exitCode === 0 && !flags.allowEmpty && !emptyShard) {
425
+ finalExit = 2;
426
+ warn('Nothing was run, so this is not a pass. Pass --allow-empty if an empty run is expected.');
427
+ manifest.exitCode = finalExit;
428
+ writeManifest();
429
+ }
430
+ }
431
+ // Enabled integrations (check runs, PR comment, issues) act on the run here, the same way
432
+ // `sdods integrations notify --run-id` does. Failures are reported and never change the exit code.
433
+ const notify = await maybeNotify({
434
+ flags,
435
+ integrations: projectCfg.integrations,
436
+ totals: summary?.totals,
437
+ exitCode,
438
+ shardTotal: cfg.runtime.shard?.total,
439
+ warn,
440
+ notify: () => notifyRun({
441
+ rootDir: ctx.rootDir,
442
+ registry: ctx.registry,
443
+ artifactsRoot: cfg.runtime.artifactsDir,
444
+ runId,
445
+ projectSlug: entry.slug,
446
+ }),
447
+ });
448
+ if (!ctx.opts.json && notify.ran && notify.actions.length) {
449
+ out('');
450
+ out(pc.bold('integrations'));
451
+ table(notify.actions.map((a) => ({
452
+ provider: a.provider,
453
+ action: a.kind,
454
+ target: a.target ?? '',
455
+ url: a.url ?? '',
456
+ detail: a.detail ?? '',
457
+ })));
458
+ }
459
+ else if (!ctx.opts.json && !notify.ran && notify.reason !== 'no integration enabled') {
460
+ // an enabled integration that did not act must say so: silence is what #81 was about
461
+ out(pc.dim(`integrations: not notified (${notify.reason})`));
462
+ }
345
463
  if (ctx.opts.json) {
346
- json({ runId, runDir, exitCode, summary, manifest });
464
+ json({ runId, runDir, exitCode: finalExit, summary, manifest, notify });
347
465
  }
348
466
  else {
349
467
  const t = summary?.totals;
350
468
  out('');
351
- out(`${exitCode === 0 ? pc.green('✔ passed') : exitCode === 130 ? pc.yellow('■ cancelled') : 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)`)}`);
469
+ 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)`)}`);
352
470
  out(pc.dim(`artifacts: ${runDir}`));
353
471
  out(pc.dim(`html report: ${join(runDir, runFiles.htmlReport, 'index.html')}`));
354
472
  out(pc.dim(`dashboard: ${join(runDir, runFiles.dashboard, 'index.html')}`));
355
473
  }
356
- return exitCode;
474
+ return finalExit;
475
+ }
476
+ /**
477
+ * `--feature` accepts a path relative to features/, to the project, to the repo, or absolute. It is
478
+ * turned into one relative to the project, and must exist: a mistyped path used to select nothing
479
+ * and pass.
480
+ */
481
+ function projectFeaturePath(projectRoot, repoRoot, input) {
482
+ const candidates = isAbsolute(input)
483
+ ? [input]
484
+ : [
485
+ join(projectRoot, 'features', input),
486
+ join(projectRoot, input),
487
+ resolvePath(repoRoot, input),
488
+ resolvePath(process.cwd(), input),
489
+ ];
490
+ const found = candidates.find((c) => existsSync(c) && statSync(c).isFile());
491
+ const rel = found ? relative(projectRoot, found).replace(/\\/g, '/') : undefined;
492
+ if (!found || !rel || rel.startsWith('..') || !rel.startsWith('features/'))
493
+ throw new SdodsError('CONFIG_NOT_FOUND', `Feature file not found in this project: ${input}`, {
494
+ hint: `Give a path under ${relative(repoRoot, join(projectRoot, 'features')) || 'features'}/, e.g. --feature auth/login.feature.`,
495
+ exitCode: 2,
496
+ });
497
+ return rel;
357
498
  }
358
499
  function escapeRe(s) {
359
500
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');