@sdods/cli 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
3
3
  import { basename, dirname, extname, join, resolve } from 'node:path';
4
4
  import { execa } from 'execa';
5
5
  import pc from 'picocolors';
6
- import { legacyRunFiles, runFiles, } from '@sdods/contracts';
6
+ import { legacyRunFiles, parseVersioned, runFiles, } from '@sdods/contracts';
7
7
  import { SdodsError, collectGateEvidence, evaluateGates, gateReportsFromRunnerJson, gateTotalsFromRunnerStats, hasGates, ingestGateJudge, } from '@sdods/core';
8
8
  import { createContext } from '../context.js';
9
9
  import { gateFailedError, printGates } from '../gates.js';
@@ -168,11 +168,12 @@ export function register(program) {
168
168
  });
169
169
  report
170
170
  .command('ingest [files...]')
171
- .description('Ingest cucumber messages NDJSON and runner JSON results into the database')
171
+ .description('Ingest cucumber messages NDJSON, runner JSON and JUnit XML results into the database')
172
172
  .requiredOption('--run-id <id>', 'run id (directory name under the artifacts root)')
173
173
  .option('-p, --project <slug>', 'project slug when run.json is missing')
174
174
  .option('--manifest <file>', 'explicit run.json path')
175
- .option('--format <fmt>', 'auto|cucumber|runner-json (alias: pw-json)', 'auto')
175
+ .option('--format <fmt>', 'auto|cucumber|runner-json (alias: pw-json)|junit', 'auto')
176
+ .option('--approved-skips <file>', 'approved-skip registry (approved-skips/v1); a skip it does not approve makes the run INFRASTRUCTURE_ERROR')
176
177
  .option('--artifacts-dir <dir>', 'artifacts root (default .sdods/runs)')
177
178
  .option('--replace', 'delete previously ingested rows of this run first')
178
179
  .option('-e, --env <name>', 'environment name when run.json is missing')
@@ -192,13 +193,18 @@ export function register(program) {
192
193
  const { basename } = await import('node:path');
193
194
  const form = new FormData();
194
195
  let count = 0;
195
- for (const f of files) {
196
+ for (const [i, f] of files.entries()) {
196
197
  const abs = resolve(ctx.rootDir, f);
197
198
  if (!existsSync(abs))
198
199
  continue;
199
- form.append('files', new Blob([readFileSync(abs)]), basename(abs));
200
+ // The server accepts result files by name; a JUnit file goes up under the name it reads.
201
+ const junit = ingestFormat(opts.format, abs) === 'junit' &&
202
+ !/^junit(\.shard-\d+)?\.xml$/.test(basename(abs));
203
+ form.append('files', new Blob([readFileSync(abs)]), junit ? `junit.shard-${i}.xml` : basename(abs));
200
204
  count++;
201
205
  }
206
+ if (opts.approvedSkips)
207
+ form.append('files', new Blob([readFileSync(resolve(ctx.rootDir, opts.approvedSkips))]), 'approved-skips.json');
202
208
  if (!count)
203
209
  throw new SdodsError('CONFIG_INVALID', 'No existing files to upload.', { exitCode: 2 });
204
210
  const url = `${serverUrl.replace(/\/$/, '')}/api/runs/${encodeURIComponent(opts.runId)}/ingest`;
@@ -223,19 +229,15 @@ export function register(program) {
223
229
  const root = opts.artifactsDir
224
230
  ? resolve(ctx.rootDir, opts.artifactsDir)
225
231
  : artifactsRoot(ctx.rootDir);
226
- const ndjson = [];
227
- const runnerJson = [];
232
+ const paths = { cucumber: [], 'runner-json': [], junit: [] };
228
233
  for (const f of files) {
229
234
  const abs = resolve(ctx.rootDir, f);
230
- const fmt = opts.format === 'auto'
231
- ? abs.endsWith('.ndjson')
232
- ? 'cucumber'
233
- : 'runner-json'
234
- : // `pw-json` is the pre-1.0 name, still accepted
235
- opts.format === 'pw-json'
236
- ? 'runner-json'
237
- : opts.format;
238
- (fmt === 'cucumber' ? ndjson : runnerJson).push(abs);
235
+ const fmt = ingestFormat(opts.format, abs);
236
+ if (!paths[fmt])
237
+ throw new SdodsError('CONFIG_INVALID', `Unknown --format "${opts.format}".`, {
238
+ exitCode: 2,
239
+ });
240
+ paths[fmt].push(abs);
239
241
  }
240
242
  const manifest = opts.manifest
241
243
  ? readJsonFile(resolve(ctx.rootDir, opts.manifest))
@@ -244,8 +246,12 @@ export function register(program) {
244
246
  runId: opts.runId,
245
247
  projectSlug: opts.project,
246
248
  manifest,
247
- ndjsonPaths: ndjson,
248
- runnerJsonPaths: runnerJson,
249
+ ndjsonPaths: paths.cucumber,
250
+ runnerJsonPaths: paths['runner-json'],
251
+ junitPaths: paths.junit,
252
+ approvedSkips: opts.approvedSkips
253
+ ? parseVersioned('approved-skips', readJsonFile(resolve(ctx.rootDir, opts.approvedSkips))).skips
254
+ : undefined,
249
255
  artifactsRoot: root,
250
256
  replace: Boolean(opts.replace),
251
257
  env: opts.env,
@@ -260,7 +266,9 @@ export function register(program) {
260
266
  return json(res);
261
267
  ok(`Ingested run ${res.runId} (${res.projectSlug}): ${res.scenarios} scenario(s), ${res.attempts} attempt(s), ${res.steps} step(s), ${res.artifacts} artifact(s), ${res.healEvents} heal event(s)`);
262
268
  const t = res.totals;
263
- out(`${pc.green(`${t.passed} passed`)} ${pc.red(`${t.failed} failed`)} ${pc.yellow(`${t.flaky} flaky`)} ${pc.dim(`${t.skipped} skipped`)} → ${res.status}`);
269
+ out(`${pc.green(`${t.passed} passed`)} ${pc.red(`${t.failed} failed`)} ${pc.yellow(`${t.flaky} flaky`)} ${pc.dim(`${t.skipped} skipped`)} → ${res.status} (${res.outcome})`);
270
+ for (const r of res.outcomeReasons)
271
+ out(pc.yellow(` ${r.code}${r.test ? ` · ${r.test}` : ''}${r.detail ? `: ${r.detail}` : ''}`));
264
272
  if (res.gates)
265
273
  out(`gates · process ${res.gates.process}: ${res.gates.passed
266
274
  ? pc.green('passed')
@@ -478,4 +486,11 @@ export function register(program) {
478
486
  await execa(file, argv, { stdio: 'inherit', cwd: ctx.rootDir });
479
487
  });
480
488
  }
489
+ /** The format of one `report ingest` file: `--format`, or its extension under `auto`. */
490
+ function ingestFormat(format, file) {
491
+ if (format === 'auto')
492
+ return file.endsWith('.ndjson') ? 'cucumber' : file.endsWith('.xml') ? 'junit' : 'runner-json';
493
+ // `pw-json` is the pre-1.0 name, still accepted
494
+ return format === 'pw-json' ? 'runner-json' : format;
495
+ }
481
496
  //# sourceMappingURL=report.js.map
@@ -1,5 +1,10 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { StepDef } from '@sdods/contracts';
3
+ /**
4
+ * The line of a failed `bddgen export` that says why, so the error names the cause rather than
5
+ * only "could not list" (SDO-71): the first `Error:` line of a stack, else the last line printed.
6
+ */
7
+ export declare function failureReason(output: string): string | undefined;
3
8
  /** Parse `bddgen export` output (`* Given ...` lines) into StepDef[]. */
4
9
  export declare function listSteps(rootDir: string, slug: string, opts?: {
5
10
  unused?: boolean;
@@ -4,6 +4,17 @@ import { SdodsError, coreStepsDir } from '@sdods/core';
4
4
  import { createContext } from '../context.js';
5
5
  import { json, table } from '../ui.js';
6
6
  import { toolCommand } from '../workspace-bin.js';
7
+ /**
8
+ * The line of a failed `bddgen export` that says why, so the error names the cause rather than
9
+ * only "could not list" (SDO-71): the first `Error:` line of a stack, else the last line printed.
10
+ */
11
+ export function failureReason(output) {
12
+ const lines = output
13
+ .split('\n')
14
+ .map((l) => l.trim())
15
+ .filter(Boolean);
16
+ return lines.find((l) => /^\w*Error\b/.test(l)) ?? lines.at(-1);
17
+ }
7
18
  /** Parse `bddgen export` output (`* Given ...` lines) into StepDef[]. */
8
19
  export async function listSteps(rootDir, slug, opts = {}) {
9
20
  const env = {
@@ -19,6 +30,7 @@ export async function listSteps(rootDir, slug, opts = {}) {
19
30
  const result = await execa(file, argv, { cwd: rootDir, env, reject: false, all: true });
20
31
  if (result.exitCode !== 0) {
21
32
  throw new SdodsError('RUN_FAILED', `Could not list the step definitions of ${slug}.`, {
33
+ hint: failureReason(result.all ?? ''),
22
34
  details: { output: result.all?.slice(-2000) },
23
35
  });
24
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.21.0",
3
+ "version": "0.22.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,22 +26,22 @@
26
26
  "node": ">=22"
27
27
  },
28
28
  "dependencies": {
29
- "@sdods/contracts": "0.21.0",
30
- "@sdods/core": "0.21.0",
29
+ "@sdods/contracts": "0.22.0",
30
+ "@sdods/core": "0.22.0",
31
31
  "commander": "^15.0.0",
32
32
  "execa": "^10.2.1",
33
33
  "picocolors": "^1.1.1",
34
34
  "semver": "^7.8.5",
35
35
  "yaml": "^2.9.1",
36
36
  "zod": "^4.6.7",
37
- "@playwright/test": "^1.64.0",
38
- "playwright-core": "^1.64.0",
39
- "@sdods/integrations": "0.21.0",
40
- "@sdods/mcp": "0.21.0",
41
- "@sdods/agents": "0.21.0",
42
- "@sdods/db": "0.21.0",
37
+ "@playwright/test": "^1.64.1",
38
+ "playwright-core": "^1.64.1",
39
+ "@sdods/integrations": "0.22.0",
40
+ "@sdods/mcp": "0.22.0",
41
+ "@sdods/agents": "0.22.0",
42
+ "@sdods/db": "0.22.0",
43
43
  "csv-parse": "^7.0.3",
44
- "@sdods/server": "0.21.0"
44
+ "@sdods/server": "0.22.0"
45
45
  },
46
46
  "homepage": "https://sdods.com",
47
47
  "bugs": {