@sdods/core 0.12.1 → 0.13.1

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.
@@ -7,6 +7,7 @@ import { Logger } from '../logger.js';
7
7
  import { CompositeDataProvider } from '../data/provider.js';
8
8
  import { AuthStateCache } from '../fixtures/auth.js';
9
9
  import { defineAuth } from './index.js';
10
+ import { toolCommand } from '../workspace-bin.js';
10
11
  const log = new Logger('auth');
11
12
  /** Users of a role from the pool dataset (capped by env.users.poolSize), without leasing them. */
12
13
  export async function poolUsers(config, role) {
@@ -80,7 +81,8 @@ async function defaultLaunch(name, headed) {
80
81
  return pw[name].launch({ headless: !headed });
81
82
  }
82
83
  async function defaultInteractive(args) {
83
- const res = await execa('npx', args, { stdio: 'inherit', reject: false });
84
+ const [file, ...argv] = toolCommand(process.cwd(), args);
85
+ const res = await execa(file, argv, { stdio: 'inherit', reject: false });
84
86
  return res.exitCode ?? 1;
85
87
  }
86
88
  /**
@@ -137,7 +139,7 @@ export async function captureAuth(opts) {
137
139
  log.step(`interactive login for ${user.username} (${user.role}) → complete the login, then close the window`);
138
140
  const code = await (opts.runInteractive ?? defaultInteractive)(args);
139
141
  if (code !== 0 && !existsSync(file)) {
140
- result.skipped = `codegen exited with ${code} and wrote no storage state`;
142
+ result.skipped = `the login window closed with code ${code} before a session was saved`;
141
143
  }
142
144
  else if (existsSync(file)) {
143
145
  cache.save(user, JSON.parse(readFileSync(file, 'utf8')));
@@ -22,6 +22,8 @@ export interface SdodsUseOption {
22
22
  browser?: BrowserName;
23
23
  }
24
24
  export declare const DASHBOARD_REPORTER = "@sdods/core/reporters/dashboard";
25
+ /** The console output of a run: scenario names and `sdods` follow-ups, not spec paths. */
26
+ export declare const TERMINAL_REPORTER = "@sdods/core/reporters/terminal";
25
27
  /** Convenience for `sdods.runner.config.ts`: read the selection from SDODS_* env vars. */
26
28
  export declare function selectionFromEnv(env?: NodeJS.ProcessEnv): RunnerSelection;
27
29
  export interface GeneratedProject {
@@ -48,6 +48,8 @@ function browserUse(browser, projectChannel) {
48
48
  return { ...devices[DEVICE_FOR_BROWSER[browser]], ...(channel ? { channel } : {}) };
49
49
  }
50
50
  export const DASHBOARD_REPORTER = '@sdods/core/reporters/dashboard';
51
+ /** The console output of a run: scenario names and `sdods` follow-ups, not spec paths. */
52
+ export const TERMINAL_REPORTER = '@sdods/core/reporters/terminal';
51
53
  /** Convenience for `sdods.runner.config.ts`: read the selection from SDODS_* env vars. */
52
54
  export function selectionFromEnv(env = process.env) {
53
55
  const list = (v) => v
@@ -268,7 +270,13 @@ export function buildRunnerConfig(registry, sel = {}) {
268
270
  return [name];
269
271
  });
270
272
  {
271
- reporter.push(reporterMode === 'server' ? ['line'] : reporterMode === 'quiet' ? ['dot'] : ['list']);
273
+ reporter.push([
274
+ TERMINAL_REPORTER,
275
+ {
276
+ mode: reporterMode === 'server' ? 'line' : reporterMode === 'quiet' ? 'dot' : 'list',
277
+ rootDir: first?.runtime.repoRoot ?? process.cwd(),
278
+ },
279
+ ]);
272
280
  reporter.push(['html', { outputFolder: join(runDir, runFiles.htmlReport), open: 'never' }]);
273
281
  // The cucumber reporter throws when no defineBddConfig() ran (recorded-only selections).
274
282
  if (bddConfigs > 0)
@@ -1,4 +1,4 @@
1
- import parseInfix from 'cucumber-tag-expressions';
1
+ import parseInfix from '@cucumber/tag-expressions';
2
2
  import { BrowserSchema } from '@sdods/contracts';
3
3
  import { SdodsError } from '../errors.js';
4
4
  export const LAYER_TAGS = ['@ui', '@api', '@hybrid'];
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './errors.js';
2
2
  export * from './logger.js';
3
+ export * from './workspace-bin.js';
3
4
  export * from './config/index.js';
4
5
  export * from './api/index.js';
5
6
  export * from './auth/index.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './errors.js';
2
2
  export * from './logger.js';
3
+ export * from './workspace-bin.js';
3
4
  export * from './config/index.js';
4
5
  export * from './api/index.js';
5
6
  export * from './auth/index.js';
@@ -8,6 +8,7 @@ import { parseFeatureFile, scenariosOf } from './gherkin.js';
8
8
  import { projectRequirements } from '../analyze/traceability.js';
9
9
  import { checkStepAmbiguity } from './steps.js';
10
10
  import { ROLES_MATRIX_FILE, planMatrixExpansion } from '../matrix/index.js';
11
+ import { toolCommand } from '../workspace-bin.js';
11
12
  export * from './gherkin.js';
12
13
  export function listFeatureFiles(root) {
13
14
  const out = [];
@@ -332,7 +333,12 @@ export async function detectUndefinedSteps(project, repoRoot) {
332
333
  SDODS_LINT: '1',
333
334
  SDODS_ARTIFACTS_DIR: join(repoRoot, '.sdods', 'lint'),
334
335
  };
335
- const result = await execa('npx', ['bddgen', '-c', join(repoRoot, 'sdods.runner.config.ts')], {
336
+ const [file, ...argv] = toolCommand(repoRoot, [
337
+ 'bddgen',
338
+ '-c',
339
+ join(repoRoot, 'sdods.runner.config.ts'),
340
+ ]);
341
+ const result = await execa(file, argv, {
336
342
  cwd: repoRoot,
337
343
  env,
338
344
  reject: false,
@@ -350,7 +356,7 @@ export async function detectUndefinedSteps(project, repoRoot) {
350
356
  if (m) {
351
357
  const stepLine = lines[i + 1]?.trim() ?? '';
352
358
  out.push({
353
- message: `Undefined step: ${stepLine || '(see bddgen output)'}`,
359
+ message: `Undefined step: ${stepLine || '(see the generator output)'}`,
354
360
  file: relative(project.root, m[1]).replace(/\\/g, '/'),
355
361
  line: Number(m[2]),
356
362
  });
@@ -390,7 +396,7 @@ export async function detectUndefinedSteps(project, repoRoot) {
390
396
  .join(' | ');
391
397
  out.push({
392
398
  rule: 'steps/bddgen',
393
- message: `bddgen failed to generate specs (exit ${result.exitCode}): ${tail || '(no output)'}`,
399
+ message: `Could not generate specs from the features (exit ${result.exitCode}): ${tail || '(no output)'}`,
394
400
  });
395
401
  }
396
402
  return out;
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { execa } from 'execa';
4
4
  import { SdodsError } from '../errors.js';
5
5
  import { harPath, safeHarName } from '../har/paths.js';
6
+ import { toolCommand } from '../workspace-bin.js';
6
7
  export function recordedDir(config) {
7
8
  return join(config.project.root, 'recorded');
8
9
  }
@@ -64,7 +65,8 @@ export async function runCodegen(opts) {
64
65
  mkdirSync(recordedDir(opts.config), { recursive: true });
65
66
  if (harFile)
66
67
  mkdirSync(join(harFile, '..'), { recursive: true });
67
- const res = await execa('npx', args, {
68
+ const [file, ...argv] = toolCommand(opts.config.runtime.repoRoot, args);
69
+ const res = await execa(file, argv, {
68
70
  cwd: opts.config.runtime.repoRoot,
69
71
  stdio: 'inherit',
70
72
  reject: false,
@@ -73,7 +75,7 @@ export async function runCodegen(opts) {
73
75
  const exitCode = res.exitCode ?? 1;
74
76
  const produced = existsSync(outputFile);
75
77
  if (exitCode !== 0 && !produced) {
76
- throw new SdodsError('RUN_FAILED', `playwright codegen exited with code ${exitCode} and produced no spec.`, {
78
+ throw new SdodsError('RUN_FAILED', `The recorder closed with code ${exitCode} before saving a spec.`, {
77
79
  hint: 'Codegen needs a display. On a headless machine, record on your workstation or import an existing spec into recorded/.',
78
80
  exitCode: 1,
79
81
  });
@@ -1,3 +1,4 @@
1
1
  export { default as DashboardReporter, type DashboardOptions } from './dashboard.js';
2
- export { DASHBOARD_REPORTER } from '../config/runner.js';
2
+ export { default as TerminalReporter, type TerminalMode, type TerminalOptions, } from './terminal.js';
3
+ export { DASHBOARD_REPORTER, TERMINAL_REPORTER } from '../config/runner.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,4 @@
1
1
  export { default as DashboardReporter } from './dashboard.js';
2
- export { DASHBOARD_REPORTER } from '../config/runner.js';
2
+ export { default as TerminalReporter, } from './terminal.js';
3
+ export { DASHBOARD_REPORTER, TERMINAL_REPORTER } from '../config/runner.js';
3
4
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,55 @@
1
+ import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestError, TestResult } from '@playwright/test/reporter';
2
+ export type TerminalMode = 'list' | 'line' | 'dot';
3
+ export interface TerminalOptions {
4
+ /** `list` (default, a line per scenario), `line` (the same, for logs and the server), `dot`. */
5
+ mode?: TerminalMode;
6
+ /** Where paths are shown relative to. Defaults to the working directory. */
7
+ rootDir?: string;
8
+ /** Output sink; tests inject one. Defaults to process.stdout. */
9
+ write?: (text: string) => void;
10
+ /** List the scenarios instead of reporting a run (`sdods run --list`; SDODS_LIST_ONLY=1). */
11
+ listOnly?: boolean;
12
+ }
13
+ /**
14
+ * The console output of `sdods run`, in SDODS terms.
15
+ *
16
+ * Replaces the runner's list/line/dot reporters, which print generated spec paths
17
+ * (`.sdods/generated/<run>/…/x.feature.spec.js`) as test names and end failures with
18
+ * `npx playwright show-trace`. Here a scenario is named by its feature and scenario title, the
19
+ * layer and browser come from the SDODS run-target name, and every follow-up is an `sdods` command.
20
+ *
21
+ * `[n/total]` stays on every scenario line: MCP `run_tests` reads progress from it.
22
+ */
23
+ export default class TerminalReporter implements Reporter {
24
+ private readonly mode;
25
+ private readonly rootDir;
26
+ private readonly write;
27
+ private total;
28
+ private done;
29
+ private dots;
30
+ private readonly failures;
31
+ private readonly flaky;
32
+ private readonly counts;
33
+ private readonly errors;
34
+ constructor(options?: TerminalOptions);
35
+ private readonly listOnly;
36
+ /** This reporter owns the console, so the runner adds no reporter of its own. */
37
+ printsToStdio(): boolean;
38
+ onBegin(config: FullConfig, suite: Suite): void;
39
+ onStdOut(chunk: string | Buffer, test?: TestCase): void;
40
+ onStdErr(chunk: string | Buffer, test?: TestCase): void;
41
+ onTestEnd(test: TestCase, result: TestResult): void;
42
+ onError(error: TestError): void;
43
+ onEnd(result: FullResult): void;
44
+ private describeFailure;
45
+ }
46
+ /** Feature › Scenario, without the runner's project and generated-file segments. */
47
+ export declare function title(test: TestCase): string;
48
+ /** `[api]`, `[ui · chromium]` — from the SDODS run-target name, not the raw project name. */
49
+ export declare function target(test: TestCase): string;
50
+ /**
51
+ * `<project>: features/<dir>/x.feature` for a scenario generated from Gherkin, read back from the
52
+ * generated spec path `.sdods/generated/<run>/<project>/<layer>/<dir>/x.feature.spec.js`.
53
+ */
54
+ export declare function featureOf(test: TestCase): string | undefined;
55
+ //# sourceMappingURL=terminal.d.ts.map
@@ -0,0 +1,232 @@
1
+ import { relative } from 'node:path';
2
+ import pc from 'picocolors';
3
+ import { parseRunnerProjectName } from '@sdods/contracts';
4
+ /**
5
+ * The console output of `sdods run`, in SDODS terms.
6
+ *
7
+ * Replaces the runner's list/line/dot reporters, which print generated spec paths
8
+ * (`.sdods/generated/<run>/…/x.feature.spec.js`) as test names and end failures with
9
+ * `npx playwright show-trace`. Here a scenario is named by its feature and scenario title, the
10
+ * layer and browser come from the SDODS run-target name, and every follow-up is an `sdods` command.
11
+ *
12
+ * `[n/total]` stays on every scenario line: MCP `run_tests` reads progress from it.
13
+ */
14
+ export default class TerminalReporter {
15
+ mode;
16
+ rootDir;
17
+ write;
18
+ total = 0;
19
+ done = 0;
20
+ dots = 0;
21
+ failures = [];
22
+ flaky = [];
23
+ counts = { passed: 0, failed: 0, skipped: 0, flaky: 0 };
24
+ errors = [];
25
+ constructor(options = {}) {
26
+ this.mode = options.mode ?? 'list';
27
+ this.rootDir = options.rootDir ?? process.cwd();
28
+ this.write = options.write ?? ((text) => process.stdout.write(text));
29
+ this.listOnly = options.listOnly ?? process.env.SDODS_LIST_ONLY === '1';
30
+ }
31
+ listOnly;
32
+ /** This reporter owns the console, so the runner adds no reporter of its own. */
33
+ printsToStdio() {
34
+ return true;
35
+ }
36
+ onBegin(config, suite) {
37
+ this.total = suite.allTests().length;
38
+ if (this.listOnly) {
39
+ const features = new Set();
40
+ for (const t of suite.allTests()) {
41
+ features.add(featureOf(t) ?? t.location.file);
42
+ this.write(` ${title(t)} ${pc.dim(target(t))}\n`);
43
+ }
44
+ this.write(`\n${this.total} scenario${this.total === 1 ? '' : 's'} in ${features.size} file${features.size === 1 ? '' : 's'}\n`);
45
+ return;
46
+ }
47
+ const workers = config.workers;
48
+ this.write(pc.dim(`Running ${this.total} scenario${this.total === 1 ? '' : 's'} with ${workers} worker${workers === 1 ? '' : 's'}\n`));
49
+ if (this.mode !== 'dot')
50
+ this.write('\n');
51
+ }
52
+ onStdOut(chunk, test) {
53
+ if (this.mode === 'dot')
54
+ return;
55
+ this.write(test ? pc.dim(String(chunk)) : String(chunk));
56
+ }
57
+ onStdErr(chunk, test) {
58
+ if (this.mode === 'dot')
59
+ return;
60
+ process.stderr.write(test ? pc.dim(String(chunk)) : String(chunk));
61
+ }
62
+ onTestEnd(test, result) {
63
+ if (this.listOnly)
64
+ return;
65
+ const final = result.status === 'passed' ||
66
+ result.status === 'skipped' ||
67
+ result.status === 'interrupted' ||
68
+ result.retry >= test.retries;
69
+ if (!final) {
70
+ if (this.mode !== 'dot')
71
+ this.write(` ${pc.yellow('↻')} ${title(test)} ${pc.dim(`${target(test)} · retry ${result.retry + 1} of ${test.retries}`)}\n`);
72
+ return;
73
+ }
74
+ this.done++;
75
+ const flaky = result.status === 'passed' && result.retry > 0;
76
+ let mark;
77
+ let dot;
78
+ if (flaky) {
79
+ this.counts.flaky++;
80
+ this.flaky.push(test);
81
+ mark = pc.yellow('±');
82
+ dot = pc.yellow('±');
83
+ }
84
+ else if (result.status === 'passed') {
85
+ this.counts.passed++;
86
+ mark = pc.green('✔');
87
+ dot = pc.green('·');
88
+ }
89
+ else if (result.status === 'skipped') {
90
+ this.counts.skipped++;
91
+ mark = pc.dim('↷');
92
+ dot = pc.dim('°');
93
+ }
94
+ else {
95
+ this.counts.failed++;
96
+ this.failures.push({ test, result });
97
+ mark = pc.red('✖');
98
+ dot = pc.red('F');
99
+ }
100
+ if (this.mode === 'dot') {
101
+ this.write(dot);
102
+ if (++this.dots % 80 === 0)
103
+ this.write('\n');
104
+ return;
105
+ }
106
+ const progress = pc.dim(`[${this.done}/${this.total}]`);
107
+ const time = pc.dim(`(${seconds(result.duration)})`);
108
+ const status = result.status === 'timedOut'
109
+ ? pc.red(' timed out')
110
+ : result.status === 'interrupted'
111
+ ? pc.yellow(' interrupted')
112
+ : '';
113
+ this.write(`${progress} ${mark} ${title(test)} ${pc.dim(target(test))}${status} ${time}\n`);
114
+ }
115
+ onError(error) {
116
+ this.errors.push(error);
117
+ this.write(`\n${pc.red('✖ Error outside a scenario')}\n${indent(formatError(error, this.rootDir), 2)}\n`);
118
+ }
119
+ onEnd(result) {
120
+ if (this.listOnly)
121
+ return;
122
+ if (this.mode === 'dot' && this.dots % 80 !== 0)
123
+ this.write('\n');
124
+ if (this.failures.length) {
125
+ this.write(`\n${pc.bold(pc.red(`Failed (${this.failures.length})`))}\n`);
126
+ this.failures.forEach((f, i) => this.write(this.describeFailure(f, i + 1)));
127
+ }
128
+ if (this.flaky.length) {
129
+ this.write(`\n${pc.bold(pc.yellow(`Flaky: passed on a retry (${this.flaky.length})`))}\n`);
130
+ for (const t of this.flaky)
131
+ this.write(` ${title(t)} ${pc.dim(target(t))}\n`);
132
+ }
133
+ const parts = [
134
+ pc.green(`${this.counts.passed} passed`),
135
+ this.counts.failed ? pc.red(`${this.counts.failed} failed`) : '',
136
+ this.counts.flaky ? pc.yellow(`${this.counts.flaky} flaky`) : '',
137
+ this.counts.skipped ? pc.dim(`${this.counts.skipped} skipped`) : '',
138
+ // Never started: a setup scenario they depend on failed, --max-failures stopped the run, or
139
+ // it was interrupted. They have no result, so they would otherwise vanish from the count.
140
+ this.total > this.done ? pc.yellow(`${this.total - this.done} did not run`) : '',
141
+ ].filter(Boolean);
142
+ this.write(`\n ${parts.join(', ')} ${pc.dim(`(${seconds(result.duration)})`)}\n`);
143
+ }
144
+ describeFailure({ test, result }, n) {
145
+ const lines = [];
146
+ const where = test.location ? relative(this.rootDir, test.location.file) : '';
147
+ lines.push(`\n ${pc.red(`${n})`)} ${pc.bold(title(test))} ${pc.dim(target(test))}`);
148
+ const feature = featureOf(test);
149
+ if (feature || where)
150
+ lines.push(pc.dim(` ${feature ?? `${where}:${test.location.line}`}`));
151
+ const errors = result.errors.length ? result.errors : result.error ? [result.error] : [];
152
+ // A generated spec is SDODS's translation of the feature, not code anyone wrote: its snippet
153
+ // and line point at the wrong file, and the feature path above is the one to open.
154
+ const generated = feature !== undefined;
155
+ for (const e of errors)
156
+ lines.push('', indent(formatError(e, this.rootDir, generated), 5));
157
+ const files = result.attachments.filter((a) => a.path);
158
+ const shots = files.filter((a) => a.contentType.startsWith('image/'));
159
+ // The before/after shots are in the dashboard; the console names the one at the failure.
160
+ const shot = shots.find((a) => /failure|failed/i.test(a.path)) ?? shots[shots.length - 1];
161
+ const video = files.find((a) => a.name === 'video');
162
+ const trace = files.find((a) => a.name === 'trace');
163
+ if (shot || video || trace)
164
+ lines.push('');
165
+ if (shot)
166
+ lines.push(` ${pc.dim('screenshot')} ${relative(this.rootDir, shot.path)}`);
167
+ if (video)
168
+ lines.push(` ${pc.dim('video ')} ${relative(this.rootDir, video.path)}`);
169
+ if (trace)
170
+ lines.push(` ${pc.dim('trace ')} ${pc.cyan(`sdods trace ${quote(relative(this.rootDir, trace.path))}`)}`);
171
+ return lines.join('\n') + '\n';
172
+ }
173
+ }
174
+ /** Feature › Scenario, without the runner's project and generated-file segments. */
175
+ export function title(test) {
176
+ // titlePath(): ['', <run target>, <spec file>, ...describe blocks, <test title>]
177
+ const path = test.titlePath().slice(3).filter(Boolean);
178
+ return (path.length ? path : [test.title]).join(' › ');
179
+ }
180
+ /** `[api]`, `[ui · chromium]` — from the SDODS run-target name, not the raw project name. */
181
+ export function target(test) {
182
+ const name = test.parent?.project()?.name ?? '';
183
+ if (!name)
184
+ return '';
185
+ const parts = parseRunnerProjectName(name);
186
+ if (!parts)
187
+ return `[${name}]`;
188
+ const bits = [
189
+ parts.layer,
190
+ parts.layer === 'api' ? undefined : parts.browser,
191
+ parts.phase, // `setup`: the gate tier other scenarios depend on
192
+ ].filter(Boolean);
193
+ return `[${bits.join(' · ')}]`;
194
+ }
195
+ /**
196
+ * `<project>: features/<dir>/x.feature` for a scenario generated from Gherkin, read back from the
197
+ * generated spec path `.sdods/generated/<run>/<project>/<layer>/<dir>/x.feature.spec.js`.
198
+ */
199
+ export function featureOf(test) {
200
+ const file = (test.location?.file ?? '').replace(/\\/g, '/');
201
+ const m = /\.sdods\/generated\/[^/]+\/([^/]+)\/[^/]+\/(.+\.feature)\.spec\.[cm]?js$/.exec(file);
202
+ return m ? `${m[1]}: features/${m[2]}` : undefined;
203
+ }
204
+ function formatError(error, rootDir, generated = false) {
205
+ const out = [];
206
+ let message = (error.message ?? error.value ?? 'Unknown error').trimEnd();
207
+ // The launch log (every browser flag, display warnings) follows "Browser logs:" and is in the
208
+ // trace; in the console it buries the one line that says what went wrong.
209
+ const logs = message.indexOf('\nBrowser logs:');
210
+ if (logs >= 0)
211
+ message = message.slice(0, logs).trimEnd() + pc.dim('\n(browser log: see the trace)');
212
+ out.push(message);
213
+ if (error.snippet && !generated)
214
+ out.push('', error.snippet);
215
+ if (error.location && !generated)
216
+ out.push(pc.dim(`at ${relative(rootDir, error.location.file)}:${error.location.line}`));
217
+ return out.join('\n');
218
+ }
219
+ function indent(text, n) {
220
+ const pad = ' '.repeat(n);
221
+ return text
222
+ .split('\n')
223
+ .map((l) => (l ? pad + l : l))
224
+ .join('\n');
225
+ }
226
+ function seconds(ms) {
227
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
228
+ }
229
+ function quote(p) {
230
+ return /[\s'"$`\\]/.test(p) ? `'${p.replace(/'/g, `'\\''`)}'` : p;
231
+ }
232
+ //# sourceMappingURL=terminal.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * How to run a package's CLI from the workspace: `[node, <its bin script>]` when the package is
3
+ * installed there, else `['npx', <bin>]`.
4
+ *
5
+ * `npx <bin>` only finds a tool through a `node_modules/.bin` shim. Some layouts have the package
6
+ * but no shim npx recognises (bun on Windows writes `.bunx` shims), and npx then downloads an
7
+ * unrelated registry package of the same name: `npx bddgen` fetched `bddgen@1.0.5` and every run
8
+ * failed with "bddgen failed to generate specs". Resolving the package and running its bin with
9
+ * this Node works in every layout.
10
+ */
11
+ export declare function workspaceBin(rootDir: string, pkg: string, bin: string): [string, ...string[]];
12
+ /**
13
+ * `[file, ...argv]` for a runner tool invocation written as `['playwright', 'test', …]` or
14
+ * `['bddgen', …]`, resolved through {@link workspaceBin}. Every SDODS command that drives the
15
+ * runner goes through here rather than a bare `npx`, which can prompt to download a package or
16
+ * pick up an unrelated one of the same name.
17
+ */
18
+ export declare function toolCommand(rootDir: string, args: readonly string[]): [string, ...string[]];
19
+ //# sourceMappingURL=workspace-bin.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ /**
5
+ * How to run a package's CLI from the workspace: `[node, <its bin script>]` when the package is
6
+ * installed there, else `['npx', <bin>]`.
7
+ *
8
+ * `npx <bin>` only finds a tool through a `node_modules/.bin` shim. Some layouts have the package
9
+ * but no shim npx recognises (bun on Windows writes `.bunx` shims), and npx then downloads an
10
+ * unrelated registry package of the same name: `npx bddgen` fetched `bddgen@1.0.5` and every run
11
+ * failed with "bddgen failed to generate specs". Resolving the package and running its bin with
12
+ * this Node works in every layout.
13
+ */
14
+ export function workspaceBin(rootDir, pkg, bin) {
15
+ try {
16
+ const manifest = createRequire(join(rootDir, 'package.json')).resolve(`${pkg}/package.json`);
17
+ const { bin: bins } = JSON.parse(readFileSync(manifest, 'utf8'));
18
+ const rel = typeof bins === 'string' ? bins : bins?.[bin];
19
+ if (rel)
20
+ return [process.execPath, join(dirname(manifest), rel)];
21
+ }
22
+ catch {
23
+ // not resolvable from here: fall back to npx
24
+ }
25
+ return ['npx', bin];
26
+ }
27
+ /** The packages behind the runner tools SDODS drives, keyed by the bin name. */
28
+ const TOOL_PACKAGES = {
29
+ playwright: '@playwright/test',
30
+ bddgen: 'playwright-bdd',
31
+ };
32
+ /**
33
+ * `[file, ...argv]` for a runner tool invocation written as `['playwright', 'test', …]` or
34
+ * `['bddgen', …]`, resolved through {@link workspaceBin}. Every SDODS command that drives the
35
+ * runner goes through here rather than a bare `npx`, which can prompt to download a package or
36
+ * pick up an unrelated one of the same name.
37
+ */
38
+ export function toolCommand(rootDir, args) {
39
+ const [bin, ...rest] = args;
40
+ const pkg = bin ? TOOL_PACKAGES[bin] : undefined;
41
+ if (!bin || !pkg)
42
+ return ['npx', ...args];
43
+ const [file, ...pre] = workspaceBin(rootDir, pkg, bin);
44
+ return [file, ...pre, ...rest];
45
+ }
46
+ //# sourceMappingURL=workspace-bin.js.map
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@sdods/core",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "SDODS runtime: configuration, project registry, fixtures, step libraries, data providers, screenshot narratives and self-healing locators.",
5
5
  "license": "Apache-2.0",
6
+ "funding": "https://sdods.com/sponsor/",
6
7
  "author": "SDODS <admin@sdods.com>",
7
8
  "type": "module",
8
9
  "exports": {
@@ -25,6 +26,7 @@
25
26
  "./auth": "./dist/auth/index.js",
26
27
  "./insights": "./dist/insights/index.js",
27
28
  "./reporters/dashboard": "./dist/reporters/dashboard.js",
29
+ "./reporters/terminal": "./dist/reporters/terminal.js",
28
30
  "./reporters": "./dist/reporters/index.js",
29
31
  "./logger": "./dist/logger.js",
30
32
  "./auth/capture": "./dist/auth/capture.js"
@@ -44,25 +46,25 @@
44
46
  "playwright-bdd": ">=9"
45
47
  },
46
48
  "dependencies": {
47
- "@sdods/contracts": "0.12.1",
49
+ "@sdods/contracts": "0.13.1",
48
50
  "@cucumber/cucumber-expressions": "19.0.0",
49
51
  "@cucumber/gherkin": "^42.0.1",
50
52
  "@cucumber/messages": "^34.2.1",
51
53
  "@faker-js/faker": "^10.6.0",
52
- "@scalar/openapi-parser": "^0.29.1",
54
+ "@scalar/openapi-parser": "^0.29.12",
53
55
  "ajv": "^8.20.0",
54
56
  "ajv-formats": "^3.0.1",
55
- "csv-parse": "^7.0.2",
56
- "cucumber-tag-expressions": "^2.0.3",
57
- "dotenv": "^17.4.2",
58
- "execa": "^10.0.1",
59
- "jsonpath-plus": "^10.4.0",
57
+ "csv-parse": "^7.0.3",
58
+ "@cucumber/tag-expressions": "^11.0.1",
59
+ "dotenv": "^18.0.7",
60
+ "execa": "^10.1.0",
61
+ "jsonpath-plus": "^11.2.0",
60
62
  "picocolors": "^1.1.1",
61
63
  "ts-morph": "^28.0.0",
62
- "tsx": "^4.23.13",
64
+ "tsx": "^4.23.15",
63
65
  "yaml": "^2.9.1",
64
66
  "zod": "^4.6.5",
65
- "@sdods/db": "0.12.1"
67
+ "@sdods/db": "0.13.1"
66
68
  },
67
69
  "homepage": "https://sdods.com",
68
70
  "bugs": {