@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.36
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/commands/smoke.js +133 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/bash-classifier.js +108 -1
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
- package/dist/core/mcp/orchestrator-tools.js +595 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/repl/session.js +370 -9
- package/dist/core/repl/slash-commands.js +68 -5
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/runtime/cli.js +453 -11
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/mcp.js +66 -11
- package/dist/runtime/commands/permissions.js +23 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/status.js +11 -3
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/permissions-picker.js +78 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/tool-stream-pane.js +45 -3
- package/package.json +7 -4
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/memory/dual-write.spec.js +0 -297
- package/dist/core/memory-sync/queue.spec.js +0 -105
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
- package/dist/runtime/commands/memory.spec.js +0 -174
|
@@ -63,7 +63,8 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
63
63
|
{ name: 'agents', args: '', gloss: 'List the on-watch agent roster', group: 'Workforce dispatch' },
|
|
64
64
|
{ name: 'delegate', args: '<slug> <brief>', gloss: 'Dispatch a brief to one Tier 1 specialist (α7.5)', group: 'Workforce dispatch' },
|
|
65
65
|
{ name: 'stop', args: '<persona>', gloss: 'Stop one agent by persona slug', group: 'Workforce dispatch' },
|
|
66
|
-
{ name: 'jobs', args: '', gloss: 'List background jobs', group: 'Workforce dispatch' },
|
|
66
|
+
{ name: 'jobs', args: '[--watch]', gloss: 'List background jobs + agent-progress; --watch mounts the live Ink TUI', group: 'Workforce dispatch' },
|
|
67
|
+
{ name: 'cancel', args: '[<id> | all]', gloss: 'Halt active dispatch by id (Wave 6)', group: 'Workforce dispatch' },
|
|
67
68
|
{ name: 'ask', args: '<question>', gloss: 'Surface a yes/no modal locally (α6.3 forcing question)', group: 'Workforce dispatch' },
|
|
68
69
|
// Session
|
|
69
70
|
{ name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
|
|
@@ -81,6 +82,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
81
82
|
{ name: 'status', args: '', gloss: 'Session snapshot — id · cwd · mode · tokens · dispatches · auth', group: 'Pugi tools' },
|
|
82
83
|
{ name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
|
|
83
84
|
{ name: 'repo-map', args: '[refresh]', gloss: 'AST-light symbol summary of the workspace (leak L28)', group: 'Pugi tools' },
|
|
85
|
+
{ name: 'codegraph-status', args: '[--install|--reindex|--offer]', gloss: 'Codegraph MCP — install state, index age, symbol count, refresh CTA (Wave 6 BT 9 P2)', group: 'Pugi tools' },
|
|
84
86
|
// Settings
|
|
85
87
|
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
86
88
|
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
@@ -94,6 +96,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
94
96
|
{ name: 'onboarding', args: '[--reset|--non-interactive]', gloss: 'First-run wizard — auth / mode / style / MCP / telemetry (leak L25)', group: 'Settings' },
|
|
95
97
|
{ name: 'vim', args: '[on|off|status]', gloss: 'Toggle vim-style modal editing in the input buffer (leak L26)', group: 'Settings' },
|
|
96
98
|
{ name: 'undo', args: '', gloss: 'Revert the last successful write / edit / multi_edit (Aider walk-back, Wave 6)', group: 'Settings' },
|
|
99
|
+
{ name: 'redo', args: '', gloss: 'Reapply the most recent /undo (LIFO stack, Wave 6 cleanup)', group: 'Settings' },
|
|
97
100
|
// Meta
|
|
98
101
|
{ name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
|
|
99
102
|
{ name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
|
|
@@ -231,7 +234,48 @@ export function parseSlashCommand(input) {
|
|
|
231
234
|
return { kind: 'version' };
|
|
232
235
|
}
|
|
233
236
|
case 'jobs': {
|
|
234
|
-
|
|
237
|
+
// Wave 6 cleanup (2026-05-27): tokenise the tail so the slash
|
|
238
|
+
// can route `--watch` к the live Ink TUI (same renderer as
|
|
239
|
+
// `pugi jobs --watch`). Unknown tokens fall through silently —
|
|
240
|
+
// the slash surface is intentionally minimal vs. the shell
|
|
241
|
+
// command (which supports list/status/tail/kill subcommands
|
|
242
|
+
// through `runJobsCommand`).
|
|
243
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
244
|
+
const watch = tokens.includes('--watch') || tokens.includes('-w') || tokens[0] === 'watch';
|
|
245
|
+
return { kind: 'jobs', watch };
|
|
246
|
+
}
|
|
247
|
+
case 'cancel':
|
|
248
|
+
case 'halt': {
|
|
249
|
+
// Wave 6 small-CC-parity batch (2026-05-27):
|
|
250
|
+
//
|
|
251
|
+
// /cancel -> list active dispatches
|
|
252
|
+
// /cancel all -> halt every running dispatch
|
|
253
|
+
// /cancel <id> -> halt one (id may be a prefix; runner
|
|
254
|
+
// does startsWith lookup)
|
|
255
|
+
//
|
|
256
|
+
// The `halt` alias matches operator muscle memory from systemd /
|
|
257
|
+
// brand voice (`stop` is already taken by /stop <persona>; cancel
|
|
258
|
+
// is dispatch-id-keyed, stop is persona-keyed). Unknown extra
|
|
259
|
+
// tokens are tolerated — the runner reads only the first.
|
|
260
|
+
const trimmedTail = tail.trim();
|
|
261
|
+
if (trimmedTail.length === 0) {
|
|
262
|
+
return { kind: 'cancel', mode: 'list', dispatchId: '' };
|
|
263
|
+
}
|
|
264
|
+
const firstToken = trimmedTail.split(/\s+/)[0].toLowerCase();
|
|
265
|
+
if (firstToken === 'all' || firstToken === '*') {
|
|
266
|
+
return { kind: 'cancel', mode: 'all', dispatchId: 'all' };
|
|
267
|
+
}
|
|
268
|
+
// Defensive: dispatch ids are filename-safe per the
|
|
269
|
+
// `validateAgentProgress` agentId regex (`[a-zA-Z0-9_-]+`).
|
|
270
|
+
// Reject anything outside that range with a usage tip so the
|
|
271
|
+
// operator sees the typo before the round-trip.
|
|
272
|
+
if (!/^[A-Za-z0-9_-]+$/.test(firstToken)) {
|
|
273
|
+
return {
|
|
274
|
+
kind: 'error',
|
|
275
|
+
message: `/cancel: invalid dispatch id '${firstToken}'. Use letters / digits / '-' / '_' only.`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return { kind: 'cancel', mode: 'one', dispatchId: firstToken };
|
|
235
279
|
}
|
|
236
280
|
case 'ask': {
|
|
237
281
|
if (tail.length === 0) {
|
|
@@ -477,6 +521,17 @@ export function parseSlashCommand(input) {
|
|
|
477
521
|
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
478
522
|
return { kind: 'chain', args: tokens };
|
|
479
523
|
}
|
|
524
|
+
case 'codegraph-status':
|
|
525
|
+
case 'codegraph': {
|
|
526
|
+
// Wave 6 BT 9 Phase 2 (2026-05-27): forward the tokenized argv
|
|
527
|
+
// to `runCodegraphStatusCommand`. Flags handled by the runner:
|
|
528
|
+
// --install — merge codegraph into .pugi/mcp.json (accept)
|
|
529
|
+
// --reindex — stamp lastIndexedAt + hint runtime to refresh
|
|
530
|
+
// --offer — surface the install prompt even after a decline
|
|
531
|
+
// `/codegraph` is the short alias; same handler.
|
|
532
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
533
|
+
return { kind: 'codegraph-status', args: tokens };
|
|
534
|
+
}
|
|
480
535
|
case 'compact': {
|
|
481
536
|
// Leak L8 (2026-05-27): graduated from stub. The session module
|
|
482
537
|
// owns the summariser round-trip. Wave 6 BT 8: `--force` overrides
|
|
@@ -561,11 +616,19 @@ export function parseSlashCommand(input) {
|
|
|
561
616
|
// Wave 6 final (2026-05-27): graduated from stub. Tail args are
|
|
562
617
|
// ignored — `runUndoCommand` is parameterless (single-step revert
|
|
563
618
|
// of the most recent successful mutating tool result). Multiple
|
|
564
|
-
// undos = stack of single-step undos.
|
|
565
|
-
//
|
|
566
|
-
//
|
|
619
|
+
// undos = stack of single-step undos. `/redo` is the counterpart
|
|
620
|
+
// (Wave 6 cleanup) — operators ping-pong через undo/redo on the
|
|
621
|
+
// event-log stack without re-running the underlying tool.
|
|
567
622
|
return { kind: 'undo' };
|
|
568
623
|
}
|
|
624
|
+
case 'redo': {
|
|
625
|
+
// Wave 6 cleanup (2026-05-27): counterpart к /undo. Tail args
|
|
626
|
+
// are ignored — `runRedoCommand` is parameterless. Each /redo
|
|
627
|
+
// pops one entry from the LIFO undo stack (the runner tracks
|
|
628
|
+
// which undos have already been consumed by previous redos so
|
|
629
|
+
// double-/redo is a noop, not a double-write).
|
|
630
|
+
return { kind: 'redo' };
|
|
631
|
+
}
|
|
569
632
|
case 'memory':
|
|
570
633
|
case 'config':
|
|
571
634
|
case 'budget': {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless subprocess driver for the smoke harness (Phase 1, 2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Spawns `pugi --headless`, feeds the scenario's user-input lines one
|
|
5
|
+
* at a time, and collects every JSON envelope written to stdout. The
|
|
6
|
+
* driver is the bridge between the in-process scenario parser and the
|
|
7
|
+
* real CLI binary — running scenarios via subprocess is what lets
|
|
8
|
+
* `pugi smoke` validate the AS-PUBLISHED CLI, not just the in-process
|
|
9
|
+
* code path.
|
|
10
|
+
*
|
|
11
|
+
* Phase 1 invariants:
|
|
12
|
+
*
|
|
13
|
+
* - One scenario = one subprocess. We do NOT reuse processes across
|
|
14
|
+
* scenarios; each run starts in a fresh temp workspace so file
|
|
15
|
+
* assertions are deterministic.
|
|
16
|
+
*
|
|
17
|
+
* - Stdin is fed line-by-line on a timer (default 200ms gap). We
|
|
18
|
+
* could fire all lines at once, but spacing them out gives the
|
|
19
|
+
* engine a chance to emit `persona-turn` envelopes between user
|
|
20
|
+
* turns. Mirrors real-operator typing cadence — feeds a more
|
|
21
|
+
* realistic test signal than a burst write.
|
|
22
|
+
*
|
|
23
|
+
* - Hard timeout per scenario (default 30s). The driver kills the
|
|
24
|
+
* subprocess on timeout and surfaces a clean executor-error so the
|
|
25
|
+
* report still shows the row instead of hanging CI.
|
|
26
|
+
*
|
|
27
|
+
* - Stderr is captured but NOT parsed. We forward it to the optional
|
|
28
|
+
* `onStderr` sink so the operator can see what the headless CLI
|
|
29
|
+
* wrote to stderr (banners, MCP load notices) — handy for
|
|
30
|
+
* debugging a failing scenario locally.
|
|
31
|
+
*/
|
|
32
|
+
import { spawn } from 'node:child_process';
|
|
33
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
34
|
+
import { tmpdir } from 'node:os';
|
|
35
|
+
import { resolve } from 'node:path';
|
|
36
|
+
/**
|
|
37
|
+
* Run a parsed scenario through the headless CLI. Returns the captured
|
|
38
|
+
* envelope stream + the workspace root the process ran in so the
|
|
39
|
+
* runner can resolve `EXPECT_FILE` paths.
|
|
40
|
+
*/
|
|
41
|
+
export async function runHeadlessScenario(scenario, opts = {}) {
|
|
42
|
+
const pugiBin = opts.pugiBin ?? 'pugi';
|
|
43
|
+
const extraArgs = opts.extraArgs ?? [];
|
|
44
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
45
|
+
const lineGapMs = opts.lineGapMs ?? 200;
|
|
46
|
+
const onStderr = opts.onStderr ?? noopChunk;
|
|
47
|
+
const spawner = opts.spawner ?? defaultSpawner;
|
|
48
|
+
const workspaceRoot = mkdtempSync(resolve(tmpdir(), 'pugi-smoke-'));
|
|
49
|
+
let cleanedUp = false;
|
|
50
|
+
const args = ['--headless', ...extraArgs];
|
|
51
|
+
const child = spawner(pugiBin, args, {
|
|
52
|
+
cwd: workspaceRoot,
|
|
53
|
+
env: { ...process.env, PUGI_HEADLESS: '1' },
|
|
54
|
+
});
|
|
55
|
+
const envelopes = [];
|
|
56
|
+
let stdoutBuffer = '';
|
|
57
|
+
child.stdout.setEncoding('utf8');
|
|
58
|
+
child.stdout.on('data', (chunk) => {
|
|
59
|
+
stdoutBuffer += chunk;
|
|
60
|
+
let newlineIndex = stdoutBuffer.indexOf('\n');
|
|
61
|
+
while (newlineIndex !== -1) {
|
|
62
|
+
const line = stdoutBuffer.slice(0, newlineIndex);
|
|
63
|
+
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
|
64
|
+
const parsed = parseEnvelopeLine(line);
|
|
65
|
+
if (parsed)
|
|
66
|
+
envelopes.push(parsed);
|
|
67
|
+
newlineIndex = stdoutBuffer.indexOf('\n');
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
child.stderr.setEncoding('utf8');
|
|
71
|
+
child.stderr.on('data', (chunk) => onStderr(chunk));
|
|
72
|
+
const userInputs = scenario.steps
|
|
73
|
+
.filter((s) => s.kind === 'user-input')
|
|
74
|
+
.map((s) => s.body);
|
|
75
|
+
// Feed inputs serially with a small gap. We don't await the engine's
|
|
76
|
+
// response — Phase 1 evaluates the WHOLE envelope stream after the
|
|
77
|
+
// process closes, so the gap is purely to give the engine room to
|
|
78
|
+
// emit between turns. A future phase can swap this for a barrier
|
|
79
|
+
// ("wait for `persona-turn` before sending the next `user-turn`")
|
|
80
|
+
// once envelope ordering is sealed.
|
|
81
|
+
const feedPromise = (async () => {
|
|
82
|
+
for (const line of userInputs) {
|
|
83
|
+
child.stdin.write(`${line}\n`);
|
|
84
|
+
await delay(lineGapMs);
|
|
85
|
+
}
|
|
86
|
+
child.stdin.end();
|
|
87
|
+
})();
|
|
88
|
+
const timeoutHandle = setTimeout(() => {
|
|
89
|
+
child.kill('SIGTERM');
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
// Don't keep node alive purely for the timeout — the spawned child
|
|
92
|
+
// already holds the loop via its IPC pipe.
|
|
93
|
+
timeoutHandle.unref?.();
|
|
94
|
+
await new Promise((resolvePromise) => {
|
|
95
|
+
child.on('close', () => resolvePromise());
|
|
96
|
+
});
|
|
97
|
+
clearTimeout(timeoutHandle);
|
|
98
|
+
// Flush any tail bytes that did not end with a newline.
|
|
99
|
+
if (stdoutBuffer.length > 0) {
|
|
100
|
+
const parsed = parseEnvelopeLine(stdoutBuffer);
|
|
101
|
+
if (parsed)
|
|
102
|
+
envelopes.push(parsed);
|
|
103
|
+
stdoutBuffer = '';
|
|
104
|
+
}
|
|
105
|
+
await feedPromise.catch(() => {
|
|
106
|
+
/* feed promise rejects when the child closes early — ignore */
|
|
107
|
+
});
|
|
108
|
+
// Cleanup the temp workspace lazily — the orchestrator may want to
|
|
109
|
+
// inspect it after the run. Callers that need a fresh dir per scenario
|
|
110
|
+
// should pass a custom workspaceRoot in a future phase; for now the
|
|
111
|
+
// runner resolves EXPECT_FILE paths against this directory and the
|
|
112
|
+
// OS reaps the tmp tree on reboot.
|
|
113
|
+
// (Explicit cleanup helper exposed for tests that want determinism.)
|
|
114
|
+
void cleanedUp;
|
|
115
|
+
return { envelopes, workspaceRoot };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Tear down a workspace created by `runHeadlessScenario`. Exposed
|
|
119
|
+
* separately because the orchestrator wants the directory to survive
|
|
120
|
+
* the run for EXPECT_FILE evaluation.
|
|
121
|
+
*/
|
|
122
|
+
export function cleanupWorkspace(workspaceRoot) {
|
|
123
|
+
try {
|
|
124
|
+
rmSync(workspaceRoot, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* best-effort */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Parse one line of headless stdout into an envelope. Returns null
|
|
132
|
+
* when the line is empty or unparseable — non-fatal so the runner can
|
|
133
|
+
* tolerate stray banner output.
|
|
134
|
+
*/
|
|
135
|
+
export function parseEnvelopeLine(raw) {
|
|
136
|
+
const line = raw.trim();
|
|
137
|
+
if (line.length === 0)
|
|
138
|
+
return null;
|
|
139
|
+
if (!line.startsWith('{'))
|
|
140
|
+
return null;
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(line);
|
|
143
|
+
if (typeof parsed['kind'] !== 'string')
|
|
144
|
+
return null;
|
|
145
|
+
if (typeof parsed['body'] !== 'string')
|
|
146
|
+
return null;
|
|
147
|
+
const ts = typeof parsed['ts'] === 'number'
|
|
148
|
+
? parsed['ts']
|
|
149
|
+
: Number(parsed['ts'] ?? Date.now());
|
|
150
|
+
return {
|
|
151
|
+
kind: parsed['kind'],
|
|
152
|
+
body: parsed['body'],
|
|
153
|
+
ts: Number.isFinite(ts) ? ts : Date.now(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function defaultSpawner(bin, args, options) {
|
|
161
|
+
return spawn(bin, args, {
|
|
162
|
+
cwd: options.cwd,
|
|
163
|
+
env: options.env,
|
|
164
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function delay(ms) {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
const handle = setTimeout(resolve, ms);
|
|
170
|
+
handle.unref?.();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function noopChunk(_chunk) { }
|
|
174
|
+
//# sourceMappingURL=headless-driver.js.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke orchestrator — glues the scenario parser, the headless
|
|
3
|
+
* subprocess driver, and the runner into a single "load → run → report"
|
|
4
|
+
* pipeline. The CLI surface (`pugi smoke`) and the standalone script
|
|
5
|
+
* (`scripts/run-scenarios.ts`) both call into this module so the two
|
|
6
|
+
* entry points share one code path.
|
|
7
|
+
*
|
|
8
|
+
* Phase 1 boundary — `runSmoke` is responsible for:
|
|
9
|
+
*
|
|
10
|
+
* 1. Discovering scenario files under `scenariosDir` (glob match on
|
|
11
|
+
* `*.scenario.txt`).
|
|
12
|
+
* 2. Parsing each file via `parseScenario`. Parse errors are surfaced
|
|
13
|
+
* via the report but do not stop the run — every scenario gets a
|
|
14
|
+
* chance to fail with a clean diagnostic.
|
|
15
|
+
* 3. Driving each scenario through the headless executor (the
|
|
16
|
+
* executor is injected so tests can swap it for a deterministic
|
|
17
|
+
* stub; production wires `runHeadlessScenario` from
|
|
18
|
+
* `headless-driver.ts`).
|
|
19
|
+
* 4. Filtering by `--filter <pattern>` (compiles to fnmatch-lite).
|
|
20
|
+
* 5. Computing pass/fail/summary numbers.
|
|
21
|
+
*
|
|
22
|
+
* The orchestrator is intentionally synchronous (apart from the
|
|
23
|
+
* per-scenario `await`) — running scenarios in parallel is a Phase 2
|
|
24
|
+
* concern. The corpus is small and sequential output is easier to read.
|
|
25
|
+
*/
|
|
26
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
27
|
+
import { resolve } from 'node:path';
|
|
28
|
+
import { parseScenario, } from './scenario-parser.js';
|
|
29
|
+
import { runScenario, } from './runner.js';
|
|
30
|
+
/**
|
|
31
|
+
* Top-level smoke entry. Returns the report so the CLI can pretty-print
|
|
32
|
+
* it AND set `process.exitCode` deterministically.
|
|
33
|
+
*/
|
|
34
|
+
export async function runSmoke(opts) {
|
|
35
|
+
const log = opts.log ?? noopLog;
|
|
36
|
+
const now = opts.now ?? Date.now;
|
|
37
|
+
const allScenarios = loadScenariosFromDir(opts.scenariosDir);
|
|
38
|
+
const visible = opts.filter && opts.filter.length > 0
|
|
39
|
+
? filterByPattern(allScenarios, opts.filter)
|
|
40
|
+
: allScenarios;
|
|
41
|
+
const results = [];
|
|
42
|
+
let passed = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
for (const item of visible) {
|
|
45
|
+
log(`pugi smoke: running ${item.scenario.id}`);
|
|
46
|
+
if (item.parseErrors.length > 0) {
|
|
47
|
+
results.push({
|
|
48
|
+
id: item.scenario.id,
|
|
49
|
+
filePath: item.scenario.filePath,
|
|
50
|
+
status: 'parse-error',
|
|
51
|
+
durationMs: 0,
|
|
52
|
+
assertionCount: 0,
|
|
53
|
+
failures: [],
|
|
54
|
+
parseErrors: item.parseErrors,
|
|
55
|
+
});
|
|
56
|
+
failed += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let envelopes = [];
|
|
60
|
+
let workspaceRoot = '.';
|
|
61
|
+
try {
|
|
62
|
+
const out = await opts.executor(item.scenario);
|
|
63
|
+
envelopes = out.envelopes;
|
|
64
|
+
workspaceRoot = out.workspaceRoot;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
68
|
+
results.push({
|
|
69
|
+
id: item.scenario.id,
|
|
70
|
+
filePath: item.scenario.filePath,
|
|
71
|
+
status: 'executor-error',
|
|
72
|
+
durationMs: 0,
|
|
73
|
+
assertionCount: 0,
|
|
74
|
+
failures: [],
|
|
75
|
+
executorError: message,
|
|
76
|
+
});
|
|
77
|
+
failed += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const result = runScenario({
|
|
81
|
+
scenario: item.scenario,
|
|
82
|
+
envelopes,
|
|
83
|
+
workspaceRoot,
|
|
84
|
+
now,
|
|
85
|
+
});
|
|
86
|
+
results.push({
|
|
87
|
+
id: result.id,
|
|
88
|
+
filePath: item.scenario.filePath,
|
|
89
|
+
status: result.passed ? 'passed' : 'failed',
|
|
90
|
+
durationMs: result.durationMs,
|
|
91
|
+
assertionCount: result.assertionCount,
|
|
92
|
+
failures: result.failures,
|
|
93
|
+
});
|
|
94
|
+
if (result.passed)
|
|
95
|
+
passed += 1;
|
|
96
|
+
else
|
|
97
|
+
failed += 1;
|
|
98
|
+
}
|
|
99
|
+
const total = visible.length;
|
|
100
|
+
const skipped = allScenarios.length - visible.length;
|
|
101
|
+
const exitCode = failed === 0 ? 0 : 1;
|
|
102
|
+
return { total, passed, failed, skipped, results, exitCode };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Walk `dir` for `*.scenario.txt` files (non-recursive). Returns each
|
|
106
|
+
* file's parsed scenario + collected parse errors so the orchestrator
|
|
107
|
+
* can surface malformed files as failed runs rather than skipping them.
|
|
108
|
+
*/
|
|
109
|
+
export function loadScenariosFromDir(dir) {
|
|
110
|
+
let names = [];
|
|
111
|
+
try {
|
|
112
|
+
names = readdirSync(dir);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
if (!name.endsWith('.scenario.txt'))
|
|
120
|
+
continue;
|
|
121
|
+
const filePath = resolve(dir, name);
|
|
122
|
+
let stat;
|
|
123
|
+
try {
|
|
124
|
+
stat = statSync(filePath);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!stat.isFile())
|
|
130
|
+
continue;
|
|
131
|
+
const body = readFileSync(filePath, 'utf8');
|
|
132
|
+
const parsed = parseScenario(filePath, body);
|
|
133
|
+
if (parsed.scenario) {
|
|
134
|
+
out.push({ scenario: parsed.scenario, parseErrors: parsed.errors });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Stable sort by id so report output is deterministic across
|
|
138
|
+
// filesystems with different readdir order.
|
|
139
|
+
out.sort((a, b) => a.scenario.id.localeCompare(b.scenario.id));
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Render a SmokeReport into a multi-line human-readable string. Kept
|
|
144
|
+
* separate from `runSmoke` so the CLI can pick its own format (text vs
|
|
145
|
+
* JSON). The default text format mirrors `node:test`'s tap-lite output:
|
|
146
|
+
*
|
|
147
|
+
* ok 1 - identity (12ms)
|
|
148
|
+
* not ok 2 - codegen-create-file (8ms)
|
|
149
|
+
* line 5: EXPECT failed — no envelope matched ...
|
|
150
|
+
*
|
|
151
|
+
* pugi smoke: 1 passed, 1 failed
|
|
152
|
+
*/
|
|
153
|
+
export function renderReportText(report) {
|
|
154
|
+
const lines = [];
|
|
155
|
+
for (let i = 0; i < report.results.length; i += 1) {
|
|
156
|
+
const r = report.results[i];
|
|
157
|
+
if (!r)
|
|
158
|
+
continue;
|
|
159
|
+
const ordinal = i + 1;
|
|
160
|
+
if (r.status === 'passed') {
|
|
161
|
+
lines.push(`ok ${ordinal} - ${r.id} (${r.durationMs}ms)`);
|
|
162
|
+
}
|
|
163
|
+
else if (r.status === 'failed') {
|
|
164
|
+
lines.push(`not ok ${ordinal} - ${r.id} (${r.durationMs}ms)`);
|
|
165
|
+
for (const f of r.failures) {
|
|
166
|
+
lines.push(` line ${f.line}: ${f.message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (r.status === 'parse-error') {
|
|
170
|
+
lines.push(`not ok ${ordinal} - ${r.id} (parse error)`);
|
|
171
|
+
for (const e of r.parseErrors ?? []) {
|
|
172
|
+
lines.push(` line ${e.line}: ${e.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else if (r.status === 'executor-error') {
|
|
176
|
+
lines.push(`not ok ${ordinal} - ${r.id} (executor error)`);
|
|
177
|
+
lines.push(` ${r.executorError ?? 'unknown executor failure'}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
lines.push('');
|
|
181
|
+
const skippedSuffix = report.skipped > 0 ? `, ${report.skipped} skipped` : '';
|
|
182
|
+
lines.push(`pugi smoke: ${report.passed} passed, ${report.failed} failed${skippedSuffix}`);
|
|
183
|
+
return lines.join('\n');
|
|
184
|
+
}
|
|
185
|
+
function filterByPattern(scenarios, pattern) {
|
|
186
|
+
if (!pattern.includes('*')) {
|
|
187
|
+
return scenarios.filter((s) => s.scenario.id.includes(pattern));
|
|
188
|
+
}
|
|
189
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
190
|
+
const re = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
|
|
191
|
+
return scenarios.filter((s) => re.test(s.scenario.id));
|
|
192
|
+
}
|
|
193
|
+
function noopLog(_line) { }
|
|
194
|
+
//# sourceMappingURL=orchestrator.js.map
|