@sdods/cli 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ import type { Command } from 'commander';
2
+ import { planCucumberImport } from '@sdods/core';
3
+ import { type CliContext } from '../context.js';
4
+ /** Every file of the proposed project, keyed by its path from the repository root. */
5
+ export declare function cucumberProjectFiles(ctx: Pick<CliContext, 'rootDir' | 'registry'>, plan: ReturnType<typeof planCucumberImport>, envName: string): Record<string, string>;
6
+ export declare function register(program: Command): void;
7
+ //# sourceMappingURL=import.d.ts.map
@@ -0,0 +1,147 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import pc from 'picocolors';
4
+ import { stringify as toYaml } from 'yaml';
5
+ import { ProjectConfigSchema, SlugSchema } from '@sdods/contracts';
6
+ import { PROJECT_FILE, SdodsError, analyzePlaywrightSuite, planCucumberImport } from '@sdods/core';
7
+ import { createContext } from '../context.js';
8
+ import { projectTemplateFiles } from '../templates/project.js';
9
+ import { json, ok, out } from '../ui.js';
10
+ /**
11
+ * `sdods import cucumber|playwright <dir>`: map an existing suite into an SDODS project, as a
12
+ * proposal a person accepts (docs/adr/0001-import-existing-suites.md). Never writes the tree.
13
+ */
14
+ function sourceDir(dir) {
15
+ const abs = resolve(dir);
16
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
17
+ throw new SdodsError('CONFIG_INVALID', `${dir} is not a directory.`, { exitCode: 2 });
18
+ }
19
+ return abs;
20
+ }
21
+ function checkSlug(ctx, slug) {
22
+ SlugSchema.parse(slug);
23
+ if (existsSync(join(ctx.registry.projectsDir, slug, PROJECT_FILE))) {
24
+ throw new SdodsError('CONFIG_INVALID', `Project "${slug}" already exists.`, {
25
+ hint: 'Pick another --project slug; an import proposes a new project.',
26
+ exitCode: 2,
27
+ });
28
+ }
29
+ return slug;
30
+ }
31
+ /** Every file of the proposed project, keyed by its path from the repository root. */
32
+ export function cucumberProjectFiles(ctx, plan, envName) {
33
+ const config = ProjectConfigSchema.parse({
34
+ slug: plan.slug,
35
+ name: plan.slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
36
+ description: `Imported from a Cucumber-JS suite by sdods import cucumber.`,
37
+ layers: plan.layers,
38
+ browsers: ['chromium'],
39
+ envs: { default: envName, available: [envName] },
40
+ tags: { extra: plan.extraTags },
41
+ });
42
+ const scaffold = projectTemplateFiles({
43
+ config,
44
+ envName,
45
+ uiUrl: 'http://localhost:3000',
46
+ apiUrl: 'http://localhost:3000/api',
47
+ });
48
+ for (const key of Object.keys(scaffold))
49
+ if (key.startsWith('features/'))
50
+ delete scaffold[key];
51
+ const project = {
52
+ ...scaffold,
53
+ [PROJECT_FILE]: `# SDODS project, imported by sdods import cucumber. Reference: https://docs.sdods.com/docs/reference/project-yaml\n${toYaml({
54
+ slug: config.slug,
55
+ name: config.name,
56
+ description: config.description,
57
+ layers: config.layers,
58
+ browsers: config.browsers,
59
+ testIdAttribute: config.testIdAttribute,
60
+ tags: { suites: config.tags.suites, extra: config.tags.extra, roles: config.tags.roles },
61
+ envs: config.envs,
62
+ heal: { enabled: true },
63
+ retries: { ci: 2, local: 0 },
64
+ }, { lineWidth: 100 })}`,
65
+ 'IMPORT-REPORT.md': plan.report,
66
+ };
67
+ for (const f of plan.features)
68
+ project[f.path] = f.content;
69
+ if (plan.stepsFile)
70
+ project['steps/imported.steps.ts'] = plan.stepsFile;
71
+ const base = relative(ctx.rootDir, join(ctx.registry.projectsDir, plan.slug));
72
+ return Object.fromEntries(Object.entries(project).map(([k, v]) => [join(base, k), v]));
73
+ }
74
+ export function register(program) {
75
+ const cmd = program
76
+ .command('import')
77
+ .description('Map an existing Cucumber-JS or Playwright suite into an SDODS project (as a proposal)');
78
+ cmd
79
+ .command('cucumber <dir>')
80
+ .description('Read a Cucumber-JS suite (features, step definitions, cucumber.js) and propose an SDODS project')
81
+ .requiredOption('-p, --project <slug>', 'slug of the new project')
82
+ .option('--env <name>', 'first environment name', 'local')
83
+ .option('--module <name>', 'folder under features/ for the imported features', 'imported')
84
+ .option('--dry-run', 'print the report and write nothing, not even a proposal')
85
+ .action(async (dir, opts, command) => {
86
+ const ctx = createContext(command);
87
+ const slug = checkSlug(ctx, opts.project);
88
+ const plan = planCucumberImport(sourceDir(dir), { slug, module: opts.module });
89
+ if (plan.features.length === 0) {
90
+ throw new SdodsError('CONFIG_INVALID', `No .feature files under ${dir}.`, { exitCode: 2 });
91
+ }
92
+ const files = cucumberProjectFiles(ctx, plan, opts.env);
93
+ if (opts.dryRun) {
94
+ if (ctx.opts.json)
95
+ return json({ plan, files: Object.keys(files) });
96
+ out(plan.report);
97
+ return;
98
+ }
99
+ const { ProposalStore } = await import('@sdods/mcp');
100
+ const manifest = new ProposalStore(ctx.rootDir).create({
101
+ role: 'importer',
102
+ project: slug,
103
+ env: opts.env,
104
+ summary: `Import ${plan.mappings.length} scenario(s) from ${plan.features.length} Cucumber-JS feature file(s) into ${slug}: ${plan.steps.core.length} step(s) reuse the SDODS library, ${plan.steps.stubbed.length} stubbed to port.`,
105
+ files: Object.entries(files).map(([path, content]) => ({
106
+ path,
107
+ content,
108
+ op: 'add',
109
+ })),
110
+ });
111
+ if (ctx.opts.json)
112
+ return json({ proposal: manifest, plan });
113
+ ok(`proposal ${manifest.id}: ${manifest.files.length} file(s) for projects/${slug}`);
114
+ out(pc.dim(` ${plan.mappings.length} scenario(s), ${plan.steps.stubbed.length} step stub(s), ${plan.manual.length} item(s) to port by hand`));
115
+ out(` review: ${pc.cyan(`sdods proposals show ${manifest.id}`)}`);
116
+ out(` accept: ${pc.cyan(`sdods proposals accept ${manifest.id}`)}`);
117
+ });
118
+ cmd
119
+ .command('playwright <dir>')
120
+ .description('Analyse a Playwright Test suite and propose the report (converts nothing yet)')
121
+ .requiredOption('-p, --project <slug>', 'slug the suite would be imported as')
122
+ .option('--dry-run', 'print the report and write nothing')
123
+ .action(async (dir, opts, command) => {
124
+ const ctx = createContext(command);
125
+ const slug = checkSlug(ctx, opts.project);
126
+ const analysis = analyzePlaywrightSuite(sourceDir(dir), slug);
127
+ if (opts.dryRun) {
128
+ if (ctx.opts.json)
129
+ return json(analysis);
130
+ out(analysis.report);
131
+ return;
132
+ }
133
+ const { ProposalStore } = await import('@sdods/mcp');
134
+ const base = relative(ctx.rootDir, join(ctx.registry.projectsDir, slug));
135
+ const manifest = new ProposalStore(ctx.rootDir).create({
136
+ role: 'importer',
137
+ project: slug,
138
+ summary: `Analysis of a Playwright suite (${analysis.specFiles.length} spec file(s), ${analysis.tests} test(s)); converts nothing yet.`,
139
+ files: [{ path: join(base, 'IMPORT-REPORT.md'), content: analysis.report, op: 'add' }],
140
+ });
141
+ if (ctx.opts.json)
142
+ return json({ proposal: manifest, analysis });
143
+ ok(`proposal ${manifest.id}: the analysis report for ${slug}`);
144
+ out(` review: ${pc.cyan(`sdods proposals show ${manifest.id}`)}`);
145
+ });
146
+ }
147
+ //# sourceMappingURL=import.js.map
@@ -1,9 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
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 { RecordingModeSchema, newRunId, runFiles, } from '@sdods/contracts';
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';
5
+ import { RecordingModeSchema, isRunId, newRunId, runFiles, } from '@sdods/contracts';
6
+ import { SdodsError, CLI_OVERRIDES_ENV, VERSION, checkPoolCapacity, ciHealSummary, collectGateEvidence, collectHealEvents, 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 { toolCommand, workspaceBin } from '../workspace-bin.js';
@@ -68,7 +68,7 @@ function addRunOptions(cmd) {
68
68
  .option('--fail-on-flaky', 'exit 1 when any test is flaky')
69
69
  .option('--max-failures <n>', 'stop after n failures', parseIntFlag('max-failures'))
70
70
  .option('--timeout <ms>', 'per-test timeout override', parseIntFlag('timeout'))
71
- .option('--reporter <name>', 'reporter override (repeatable, name=outputFile)', collect, [])
71
+ .option('--reporter <name>', 'add a reporter: junit (XML), allure (allure-results), json, blob, or any Playwright reporter; name=path sets the output (repeatable)', collect, [])
72
72
  .option('--reporter-mode <mode>', 'default | server | quiet')
73
73
  .option('--trigger <kind>', 'cli | ui | ci | agent | mcp | schedule', 'cli')
74
74
  .option('--allow-empty', 'exit 0 when the selection matches no scenario (by default that is exit 2)')
@@ -87,6 +87,13 @@ export function register(program) {
87
87
  });
88
88
  }
89
89
  export async function runCommand(flags, cmd) {
90
+ // The run id names `.sdods/generated/<runId>`, which is removed recursively when the run ends,
91
+ // so `--run-id ../..` must be refused before anything touches the disk.
92
+ if (flags.runId !== undefined && !isRunId(flags.runId))
93
+ throw new SdodsError('CONFIG_INVALID', `Invalid run id "${flags.runId}".`, {
94
+ hint: 'Use letters, digits, ".", "_" or "-", not starting with "." (or omit --run-id).',
95
+ exitCode: 2,
96
+ });
90
97
  const ctx = createContext(cmd);
91
98
  const entry = ctx.registry.pick(flags.project);
92
99
  const projectCfg = entry.config;
@@ -469,6 +476,26 @@ export async function runCommand(flags, cmd) {
469
476
  warn(`trace redaction failed for ${relative(ctx.rootDir, e.file)}: ${e.error}`);
470
477
  }
471
478
  const summary = readSummary(runDir, runId, exitCode, Date.now() - started);
479
+ // A heal in CI means the page changed under the suite. It must never pass silently
480
+ // (heal.ciMode, default warn), so every one is listed as an annotation and in the step summary.
481
+ if (cfg.runtime.ci) {
482
+ const heals = ciHealSummary(collectHealEvents(runDir));
483
+ if (heals) {
484
+ if (process.env.GITHUB_ACTIONS === 'true' && !ctx.opts.json)
485
+ for (const a of heals.annotations)
486
+ console.log(a);
487
+ else
488
+ warn(heals.line);
489
+ if (process.env.GITHUB_STEP_SUMMARY) {
490
+ try {
491
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${heals.markdown}\n`);
492
+ }
493
+ catch {
494
+ // a read-only or missing summary file must not fail the run
495
+ }
496
+ }
497
+ }
498
+ }
472
499
  // Process gates are judged here, from the run that just finished, before anything publishes it.
473
500
  // One shard of several holds only part of the run, so its pass rate and its a11y/perf evidence
474
501
  // are not the run's; a sharded process is gated where the shards are merged
package/dist/program.js CHANGED
@@ -40,6 +40,7 @@ import { register as registerUpgrade } from './commands/upgrade.js';
40
40
  import { register as registerFeedback } from './commands/feedback.js';
41
41
  import { register as registerLoad } from './commands/load.js';
42
42
  import { register as registerMatrix } from './commands/matrix.js';
43
+ import { register as registerImport } from './commands/import.js';
43
44
  /**
44
45
  * All commands are registered eagerly (cheap) but each module keeps heavy imports
45
46
  * (server, agents, DB, browser runner) inside its action via dynamic import.
@@ -101,6 +102,7 @@ export function buildProgram() {
101
102
  registerFeedback(program);
102
103
  registerLoad(program);
103
104
  registerMatrix(program);
105
+ registerImport(program);
104
106
  return program;
105
107
  }
106
108
  //# sourceMappingURL=program.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.17.0",
3
+ "version": "0.19.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
  "funding": "https://sdods.com/sponsor/",
@@ -26,8 +26,8 @@
26
26
  "node": ">=22"
27
27
  },
28
28
  "dependencies": {
29
- "@sdods/contracts": "0.17.0",
30
- "@sdods/core": "0.17.0",
29
+ "@sdods/contracts": "0.19.0",
30
+ "@sdods/core": "0.19.0",
31
31
  "commander": "^15.0.0",
32
32
  "execa": "^10.2.1",
33
33
  "picocolors": "^1.1.1",
@@ -36,12 +36,12 @@
36
36
  "zod": "^4.6.7",
37
37
  "@playwright/test": "^1.64.0",
38
38
  "playwright-core": "^1.64.0",
39
- "@sdods/integrations": "0.17.0",
40
- "@sdods/mcp": "0.17.0",
41
- "@sdods/agents": "0.17.0",
42
- "@sdods/db": "0.17.0",
39
+ "@sdods/integrations": "0.19.0",
40
+ "@sdods/mcp": "0.19.0",
41
+ "@sdods/agents": "0.19.0",
42
+ "@sdods/db": "0.19.0",
43
43
  "csv-parse": "^7.0.3",
44
- "@sdods/server": "0.17.0"
44
+ "@sdods/server": "0.19.0"
45
45
  },
46
46
  "homepage": "https://sdods.com",
47
47
  "bugs": {
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "repository": {
51
51
  "type": "git",
52
- "url": "git+https://github.com/YarlisAISolutions/SDODS.git",
52
+ "url": "git+https://github.com/YarlisAISolutions/sdods.git",
53
53
  "directory": "packages/cli"
54
54
  },
55
55
  "keywords": [