@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/commands/browsers.d.ts +16 -2
- package/dist/commands/browsers.js +116 -13
- package/dist/commands/config.js +19 -11
- package/dist/commands/doctor.js +26 -22
- package/dist/commands/features.js +6 -4
- package/dist/commands/init.js +4 -2
- package/dist/commands/integrations.js +11 -40
- package/dist/commands/mcp.js +18 -9
- package/dist/commands/project.js +322 -6
- package/dist/commands/run.d.ts +4 -0
- package/dist/commands/run.js +156 -15
- package/dist/commands/serve.js +30 -10
- package/dist/notify.d.ts +63 -0
- package/dist/notify.js +91 -0
- package/dist/runner-compat.d.ts +19 -0
- package/dist/runner-compat.js +47 -0
- package/package.json +10 -10
- package/templates/.claude/skills/sdods-run/SKILL.md +1 -1
- package/templates/.claude/skills/sdods-start-ui/SKILL.md +313 -0
- package/templates/.env.example +1 -0
- package/templates/projects/demo-shop/sdods.project.yaml +7 -1
package/dist/commands/project.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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
|
package/dist/commands/run.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/run.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
256
|
-
filters.push(featureFilter(
|
|
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
|
-
|
|
344
|
-
|
|
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(`${
|
|
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
|
|
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, '\\$&');
|