@principles/pd-cli 1.73.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.
Files changed (298) hide show
  1. package/README.md +90 -0
  2. package/dist/commands/artifact.d.ts +14 -0
  3. package/dist/commands/artifact.d.ts.map +1 -0
  4. package/dist/commands/artifact.js +67 -0
  5. package/dist/commands/artifact.js.map +1 -0
  6. package/dist/commands/candidate.d.ts +83 -0
  7. package/dist/commands/candidate.d.ts.map +1 -0
  8. package/dist/commands/candidate.js +891 -0
  9. package/dist/commands/candidate.js.map +1 -0
  10. package/dist/commands/central-sync.d.ts +10 -0
  11. package/dist/commands/central-sync.d.ts.map +1 -0
  12. package/dist/commands/central-sync.js +32 -0
  13. package/dist/commands/central-sync.js.map +1 -0
  14. package/dist/commands/console.d.ts +9 -0
  15. package/dist/commands/console.d.ts.map +1 -0
  16. package/dist/commands/console.js +114 -0
  17. package/dist/commands/console.js.map +1 -0
  18. package/dist/commands/context.d.ts +7 -0
  19. package/dist/commands/context.d.ts.map +1 -0
  20. package/dist/commands/context.js +55 -0
  21. package/dist/commands/context.js.map +1 -0
  22. package/dist/commands/demo-story-a.d.ts +12 -0
  23. package/dist/commands/demo-story-a.d.ts.map +1 -0
  24. package/dist/commands/demo-story-a.js +175 -0
  25. package/dist/commands/demo-story-a.js.map +1 -0
  26. package/dist/commands/diagnose.d.ts +35 -0
  27. package/dist/commands/diagnose.d.ts.map +1 -0
  28. package/dist/commands/diagnose.js +390 -0
  29. package/dist/commands/diagnose.js.map +1 -0
  30. package/dist/commands/evolution-tasks-list.d.ts +15 -0
  31. package/dist/commands/evolution-tasks-list.d.ts.map +1 -0
  32. package/dist/commands/evolution-tasks-list.js +34 -0
  33. package/dist/commands/evolution-tasks-list.js.map +1 -0
  34. package/dist/commands/evolution-tasks-show.d.ts +14 -0
  35. package/dist/commands/evolution-tasks-show.d.ts.map +1 -0
  36. package/dist/commands/evolution-tasks-show.js +52 -0
  37. package/dist/commands/evolution-tasks-show.js.map +1 -0
  38. package/dist/commands/flow.d.ts +7 -0
  39. package/dist/commands/flow.d.ts.map +1 -0
  40. package/dist/commands/flow.js +57 -0
  41. package/dist/commands/flow.js.map +1 -0
  42. package/dist/commands/health.d.ts +16 -0
  43. package/dist/commands/health.d.ts.map +1 -0
  44. package/dist/commands/health.js +150 -0
  45. package/dist/commands/health.js.map +1 -0
  46. package/dist/commands/history.d.ts +11 -0
  47. package/dist/commands/history.d.ts.map +1 -0
  48. package/dist/commands/history.js +50 -0
  49. package/dist/commands/history.js.map +1 -0
  50. package/dist/commands/legacy-cleanup.d.ts +27 -0
  51. package/dist/commands/legacy-cleanup.d.ts.map +1 -0
  52. package/dist/commands/legacy-cleanup.js +171 -0
  53. package/dist/commands/legacy-cleanup.js.map +1 -0
  54. package/dist/commands/legacy-import.d.ts +7 -0
  55. package/dist/commands/legacy-import.d.ts.map +1 -0
  56. package/dist/commands/legacy-import.js +86 -0
  57. package/dist/commands/legacy-import.js.map +1 -0
  58. package/dist/commands/pain-record.d.ts +10 -0
  59. package/dist/commands/pain-record.d.ts.map +1 -0
  60. package/dist/commands/pain-record.js +162 -0
  61. package/dist/commands/pain-record.js.map +1 -0
  62. package/dist/commands/proven-channel-baseline.d.ts +12 -0
  63. package/dist/commands/proven-channel-baseline.d.ts.map +1 -0
  64. package/dist/commands/proven-channel-baseline.js +97 -0
  65. package/dist/commands/proven-channel-baseline.js.map +1 -0
  66. package/dist/commands/remediation-output.d.ts +40 -0
  67. package/dist/commands/remediation-output.d.ts.map +1 -0
  68. package/dist/commands/remediation-output.js +23 -0
  69. package/dist/commands/remediation-output.js.map +1 -0
  70. package/dist/commands/run.d.ts +10 -0
  71. package/dist/commands/run.d.ts.map +1 -0
  72. package/dist/commands/run.js +68 -0
  73. package/dist/commands/run.js.map +1 -0
  74. package/dist/commands/runtime-activation.d.ts +11 -0
  75. package/dist/commands/runtime-activation.d.ts.map +1 -0
  76. package/dist/commands/runtime-activation.js +150 -0
  77. package/dist/commands/runtime-activation.js.map +1 -0
  78. package/dist/commands/runtime-canary.d.ts +30 -0
  79. package/dist/commands/runtime-canary.d.ts.map +1 -0
  80. package/dist/commands/runtime-canary.js +343 -0
  81. package/dist/commands/runtime-canary.js.map +1 -0
  82. package/dist/commands/runtime-diagnostics-export.d.ts +20 -0
  83. package/dist/commands/runtime-diagnostics-export.d.ts.map +1 -0
  84. package/dist/commands/runtime-diagnostics-export.js +177 -0
  85. package/dist/commands/runtime-diagnostics-export.js.map +1 -0
  86. package/dist/commands/runtime-features.d.ts +26 -0
  87. package/dist/commands/runtime-features.d.ts.map +1 -0
  88. package/dist/commands/runtime-features.js +70 -0
  89. package/dist/commands/runtime-features.js.map +1 -0
  90. package/dist/commands/runtime-gfi-snapshot.d.ts +7 -0
  91. package/dist/commands/runtime-gfi-snapshot.d.ts.map +1 -0
  92. package/dist/commands/runtime-gfi-snapshot.js +101 -0
  93. package/dist/commands/runtime-gfi-snapshot.js.map +1 -0
  94. package/dist/commands/runtime-health-snapshot.d.ts +7 -0
  95. package/dist/commands/runtime-health-snapshot.d.ts.map +1 -0
  96. package/dist/commands/runtime-health-snapshot.js +93 -0
  97. package/dist/commands/runtime-health-snapshot.js.map +1 -0
  98. package/dist/commands/runtime-idle-trigger.d.ts +12 -0
  99. package/dist/commands/runtime-idle-trigger.d.ts.map +1 -0
  100. package/dist/commands/runtime-idle-trigger.js +102 -0
  101. package/dist/commands/runtime-idle-trigger.js.map +1 -0
  102. package/dist/commands/runtime-internalization-enqueue-successors.d.ts +9 -0
  103. package/dist/commands/runtime-internalization-enqueue-successors.d.ts.map +1 -0
  104. package/dist/commands/runtime-internalization-enqueue-successors.js +393 -0
  105. package/dist/commands/runtime-internalization-enqueue-successors.js.map +1 -0
  106. package/dist/commands/runtime-internalization-integrity-repair.d.ts +9 -0
  107. package/dist/commands/runtime-internalization-integrity-repair.d.ts.map +1 -0
  108. package/dist/commands/runtime-internalization-integrity-repair.js +54 -0
  109. package/dist/commands/runtime-internalization-integrity-repair.js.map +1 -0
  110. package/dist/commands/runtime-internalization-integrity.d.ts +7 -0
  111. package/dist/commands/runtime-internalization-integrity.d.ts.map +1 -0
  112. package/dist/commands/runtime-internalization-integrity.js +53 -0
  113. package/dist/commands/runtime-internalization-integrity.js.map +1 -0
  114. package/dist/commands/runtime-internalization-queue.d.ts +7 -0
  115. package/dist/commands/runtime-internalization-queue.d.ts.map +1 -0
  116. package/dist/commands/runtime-internalization-queue.js +85 -0
  117. package/dist/commands/runtime-internalization-queue.js.map +1 -0
  118. package/dist/commands/runtime-internalization-run-once.d.ts +12 -0
  119. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -0
  120. package/dist/commands/runtime-internalization-run-once.js +546 -0
  121. package/dist/commands/runtime-internalization-run-once.js.map +1 -0
  122. package/dist/commands/runtime-internalization-wake-once.d.ts +8 -0
  123. package/dist/commands/runtime-internalization-wake-once.d.ts.map +1 -0
  124. package/dist/commands/runtime-internalization-wake-once.js +72 -0
  125. package/dist/commands/runtime-internalization-wake-once.js.map +1 -0
  126. package/dist/commands/runtime-pain-flood-simulation.d.ts +10 -0
  127. package/dist/commands/runtime-pain-flood-simulation.d.ts.map +1 -0
  128. package/dist/commands/runtime-pain-flood-simulation.js +104 -0
  129. package/dist/commands/runtime-pain-flood-simulation.js.map +1 -0
  130. package/dist/commands/runtime-pruning.d.ts +45 -0
  131. package/dist/commands/runtime-pruning.d.ts.map +1 -0
  132. package/dist/commands/runtime-pruning.js +355 -0
  133. package/dist/commands/runtime-pruning.js.map +1 -0
  134. package/dist/commands/runtime-recovery.d.ts +9 -0
  135. package/dist/commands/runtime-recovery.d.ts.map +1 -0
  136. package/dist/commands/runtime-recovery.js +94 -0
  137. package/dist/commands/runtime-recovery.js.map +1 -0
  138. package/dist/commands/runtime-synthetic-baseline.d.ts +7 -0
  139. package/dist/commands/runtime-synthetic-baseline.d.ts.map +1 -0
  140. package/dist/commands/runtime-synthetic-baseline.js +59 -0
  141. package/dist/commands/runtime-synthetic-baseline.js.map +1 -0
  142. package/dist/commands/runtime-uat.d.ts +52 -0
  143. package/dist/commands/runtime-uat.d.ts.map +1 -0
  144. package/dist/commands/runtime-uat.js +274 -0
  145. package/dist/commands/runtime-uat.js.map +1 -0
  146. package/dist/commands/runtime.d.ts +20 -0
  147. package/dist/commands/runtime.d.ts.map +1 -0
  148. package/dist/commands/runtime.js +256 -0
  149. package/dist/commands/runtime.js.map +1 -0
  150. package/dist/commands/samples-list.d.ts +11 -0
  151. package/dist/commands/samples-list.d.ts.map +1 -0
  152. package/dist/commands/samples-list.js +37 -0
  153. package/dist/commands/samples-list.js.map +1 -0
  154. package/dist/commands/samples-review.d.ts +14 -0
  155. package/dist/commands/samples-review.d.ts.map +1 -0
  156. package/dist/commands/samples-review.js +22 -0
  157. package/dist/commands/samples-review.js.map +1 -0
  158. package/dist/commands/task.d.ts +14 -0
  159. package/dist/commands/task.d.ts.map +1 -0
  160. package/dist/commands/task.js +92 -0
  161. package/dist/commands/task.js.map +1 -0
  162. package/dist/commands/trace.d.ts +19 -0
  163. package/dist/commands/trace.d.ts.map +1 -0
  164. package/dist/commands/trace.js +154 -0
  165. package/dist/commands/trace.js.map +1 -0
  166. package/dist/commands/trajectory.d.ts +11 -0
  167. package/dist/commands/trajectory.d.ts.map +1 -0
  168. package/dist/commands/trajectory.js +47 -0
  169. package/dist/commands/trajectory.js.map +1 -0
  170. package/dist/index.d.ts +9 -0
  171. package/dist/index.d.ts.map +1 -0
  172. package/dist/index.js +736 -0
  173. package/dist/index.js.map +1 -0
  174. package/dist/legacy/legacy-import.d.ts +15 -0
  175. package/dist/legacy/legacy-import.d.ts.map +1 -0
  176. package/dist/legacy/legacy-import.js +141 -0
  177. package/dist/legacy/legacy-import.js.map +1 -0
  178. package/dist/legacy/session-history-import.d.ts +26 -0
  179. package/dist/legacy/session-history-import.d.ts.map +1 -0
  180. package/dist/legacy/session-history-import.js +151 -0
  181. package/dist/legacy/session-history-import.js.map +1 -0
  182. package/dist/principle-tree-ledger-adapter.d.ts +12 -0
  183. package/dist/principle-tree-ledger-adapter.d.ts.map +1 -0
  184. package/dist/principle-tree-ledger-adapter.js +12 -0
  185. package/dist/principle-tree-ledger-adapter.js.map +1 -0
  186. package/dist/resolve-workspace.d.ts +12 -0
  187. package/dist/resolve-workspace.d.ts.map +1 -0
  188. package/dist/resolve-workspace.js +20 -0
  189. package/dist/resolve-workspace.js.map +1 -0
  190. package/dist/services/demo-story-a-runner.d.ts +8 -0
  191. package/dist/services/demo-story-a-runner.d.ts.map +1 -0
  192. package/dist/services/demo-story-a-runner.js +369 -0
  193. package/dist/services/demo-story-a-runner.js.map +1 -0
  194. package/dist/services/feature-flag-loader.d.ts +6 -0
  195. package/dist/services/feature-flag-loader.d.ts.map +1 -0
  196. package/dist/services/feature-flag-loader.js +54 -0
  197. package/dist/services/feature-flag-loader.js.map +1 -0
  198. package/dist/services/pain-flood-simulation-runner.d.ts +10 -0
  199. package/dist/services/pain-flood-simulation-runner.d.ts.map +1 -0
  200. package/dist/services/pain-flood-simulation-runner.js +289 -0
  201. package/dist/services/pain-flood-simulation-runner.js.map +1 -0
  202. package/dist/services/proven-channel-baseline-runner.d.ts +12 -0
  203. package/dist/services/proven-channel-baseline-runner.d.ts.map +1 -0
  204. package/dist/services/proven-channel-baseline-runner.js +114 -0
  205. package/dist/services/proven-channel-baseline-runner.js.map +1 -0
  206. package/dist/services/synthetic-baseline-runner.d.ts +8 -0
  207. package/dist/services/synthetic-baseline-runner.d.ts.map +1 -0
  208. package/dist/services/synthetic-baseline-runner.js +251 -0
  209. package/dist/services/synthetic-baseline-runner.js.map +1 -0
  210. package/package.json +35 -0
  211. package/src/commands/artifact.ts +82 -0
  212. package/src/commands/candidate.ts +1117 -0
  213. package/src/commands/central-sync.ts +44 -0
  214. package/src/commands/console.ts +121 -0
  215. package/src/commands/context.ts +72 -0
  216. package/src/commands/demo-story-a.ts +195 -0
  217. package/src/commands/diagnose.ts +452 -0
  218. package/src/commands/evolution-tasks-list.ts +44 -0
  219. package/src/commands/evolution-tasks-show.ts +60 -0
  220. package/src/commands/flow.ts +60 -0
  221. package/src/commands/health.ts +189 -0
  222. package/src/commands/history.ts +63 -0
  223. package/src/commands/legacy-cleanup.ts +206 -0
  224. package/src/commands/legacy-import.ts +104 -0
  225. package/src/commands/pain-record.ts +167 -0
  226. package/src/commands/proven-channel-baseline.ts +113 -0
  227. package/src/commands/remediation-output.ts +66 -0
  228. package/src/commands/run.ts +89 -0
  229. package/src/commands/runtime-activation.ts +176 -0
  230. package/src/commands/runtime-canary.ts +371 -0
  231. package/src/commands/runtime-diagnostics-export.ts +229 -0
  232. package/src/commands/runtime-features.ts +103 -0
  233. package/src/commands/runtime-gfi-snapshot.ts +135 -0
  234. package/src/commands/runtime-health-snapshot.ts +106 -0
  235. package/src/commands/runtime-internalization-enqueue-successors.ts +479 -0
  236. package/src/commands/runtime-internalization-integrity-repair.ts +69 -0
  237. package/src/commands/runtime-internalization-integrity.ts +63 -0
  238. package/src/commands/runtime-internalization-queue.ts +106 -0
  239. package/src/commands/runtime-internalization-run-once.ts +658 -0
  240. package/src/commands/runtime-internalization-wake-once.ts +87 -0
  241. package/src/commands/runtime-pain-flood-simulation.ts +121 -0
  242. package/src/commands/runtime-pruning.ts +438 -0
  243. package/src/commands/runtime-recovery.ts +107 -0
  244. package/src/commands/runtime-synthetic-baseline.ts +70 -0
  245. package/src/commands/runtime-uat.ts +339 -0
  246. package/src/commands/runtime.ts +281 -0
  247. package/src/commands/samples-list.ts +43 -0
  248. package/src/commands/samples-review.ts +32 -0
  249. package/src/commands/task.ts +130 -0
  250. package/src/commands/trace.ts +174 -0
  251. package/src/commands/trajectory.ts +64 -0
  252. package/src/index.ts +829 -0
  253. package/src/legacy/legacy-import.ts +179 -0
  254. package/src/legacy/session-history-import.ts +231 -0
  255. package/src/principle-tree-ledger-adapter.ts +13 -0
  256. package/src/resolve-workspace.ts +20 -0
  257. package/src/services/demo-story-a-runner.ts +472 -0
  258. package/src/services/feature-flag-loader.ts +73 -0
  259. package/src/services/pain-flood-simulation-runner.ts +354 -0
  260. package/src/services/proven-channel-baseline-runner.ts +150 -0
  261. package/src/services/synthetic-baseline-runner.ts +291 -0
  262. package/tests/commands/candidate-audit-repair.test.ts +338 -0
  263. package/tests/commands/candidate-intake.test.ts +589 -0
  264. package/tests/commands/candidate-internalization-backfill.test.ts +480 -0
  265. package/tests/commands/candidate-internalize.test.ts +272 -0
  266. package/tests/commands/candidate-route.test.ts +328 -0
  267. package/tests/commands/candidate-show.test.ts +95 -0
  268. package/tests/commands/cli-command-tree.test.ts +64 -0
  269. package/tests/commands/context.test.ts +114 -0
  270. package/tests/commands/demo-story-a.test.ts +255 -0
  271. package/tests/commands/diagnose.test.ts +792 -0
  272. package/tests/commands/health.test.ts +330 -0
  273. package/tests/commands/pain-record.test.ts +316 -0
  274. package/tests/commands/plugin-config-resolution-cutover.test.ts +220 -0
  275. package/tests/commands/proven-channel-baseline.test.ts +441 -0
  276. package/tests/commands/runtime-activation.test.ts +168 -0
  277. package/tests/commands/runtime-canary.test.ts +369 -0
  278. package/tests/commands/runtime-diagnostics-export.test.ts +170 -0
  279. package/tests/commands/runtime-features.test.ts +114 -0
  280. package/tests/commands/runtime-health-snapshot.test.ts +357 -0
  281. package/tests/commands/runtime-internalization-enqueue-successors.test.ts +803 -0
  282. package/tests/commands/runtime-internalization-integrity-repair.test.ts +169 -0
  283. package/tests/commands/runtime-internalization-integrity.test.ts +102 -0
  284. package/tests/commands/runtime-internalization-queue.test.ts +252 -0
  285. package/tests/commands/runtime-internalization-run-once.test.ts +1318 -0
  286. package/tests/commands/runtime-internalization-wake-once.test.ts +170 -0
  287. package/tests/commands/runtime-internalization.test.ts +52 -0
  288. package/tests/commands/runtime-pain-flood-simulation.test.ts +418 -0
  289. package/tests/commands/runtime-pruning.test.ts +693 -0
  290. package/tests/commands/runtime-recovery.test.ts +96 -0
  291. package/tests/commands/runtime-synthetic-baseline.test.ts +249 -0
  292. package/tests/commands/runtime-uat.test.ts +397 -0
  293. package/tests/commands/runtime.test.ts +262 -0
  294. package/tests/commands/trace.test.ts +314 -0
  295. package/tests/e2e/candidate-intake-e2e.test.ts +316 -0
  296. package/tests/services/feature-flag-loader.test.ts +207 -0
  297. package/tests/services/proven-channel-baseline-runner.test.ts +30 -0
  298. package/tsconfig.json +26 -0
@@ -0,0 +1,397 @@
1
+ /**
2
+ * pd runtime uat CLI unit tests.
3
+ * Tests parseUatArgs, computeUatSummary, shouldExitWithError, runUatIteration.
4
+ * Integration tests with real pd calls are done manually via
5
+ * node scripts/uat/runtime-v2-chain-uat.mjs --workspace <path> --count 2 --json
6
+ */
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+
11
+ // ── Mock child_process before importing the module under test ────────────────
12
+
13
+ const { mockExecFileSync, mockExistsSync } = vi.hoisted(() => ({
14
+ mockExecFileSync: vi.fn(),
15
+ mockExistsSync: vi.fn(),
16
+ }));
17
+
18
+ vi.mock('child_process', () => ({
19
+ execFileSync: mockExecFileSync,
20
+ }));
21
+
22
+ vi.mock('fs', () => ({
23
+ existsSync: mockExistsSync,
24
+ readFileSync: vi.fn(),
25
+ }));
26
+
27
+ vi.mock(process.execPath, '/mock/node', { spy: false });
28
+
29
+ // ── Import after mock setup ─────────────────────────────────────────────────
30
+
31
+ import {
32
+ parseUatArgs,
33
+ computeUatSummary,
34
+ shouldExitWithError,
35
+ type PainRecordResult,
36
+ } from '../../src/commands/runtime-uat.js';
37
+
38
+ // ── parseUatArgs ─────────────────────────────────────────────────────────────
39
+
40
+ describe('parseUatArgs', () => {
41
+ it('defaults count to 5 when not provided', () => {
42
+ const result = parseUatArgs([]);
43
+ expect(result.count).toBe(5);
44
+ });
45
+
46
+ it('parses --count', () => {
47
+ const result = parseUatArgs(['--count', '10']);
48
+ expect(result.count).toBe(10);
49
+ });
50
+
51
+ it('parses --workspace', () => {
52
+ const result = parseUatArgs(['--workspace', '/tmp/ws']);
53
+ expect(result.workspace).toBe('/tmp/ws');
54
+ });
55
+
56
+ it('parses --min-success-rate', () => {
57
+ const result = parseUatArgs(['--min-success-rate', '0.8']);
58
+ expect(result.minSuccessRate).toBe(0.8);
59
+ });
60
+
61
+ it('parses -w as alias for --workspace', () => {
62
+ const result = parseUatArgs(['-w', '/custom/path']);
63
+ expect(result.workspace).toBe('/custom/path');
64
+ });
65
+
66
+ it('clamps count to [1, 50]', () => {
67
+ expect(parseUatArgs(['--count', '1']).count).toBe(1);
68
+ expect(parseUatArgs(['--count', '50']).count).toBe(50);
69
+ });
70
+ });
71
+
72
+ // ── computeUatSummary ────────────────────────────────────────────────────────
73
+
74
+ describe('computeUatSummary', () => {
75
+ const ws = '/tmp/test-workspace';
76
+
77
+ it('computes successRate correctly', () => {
78
+ const results: PainRecordResult[] = [
79
+ makeResult(1, 'succeeded', ['c1'], ['l1']),
80
+ makeResult(2, 'succeeded', ['c2'], ['l2']),
81
+ makeResult(3, 'failed', ['c3'], ['l3']),
82
+ ];
83
+ const summary = computeUatSummary(results, ws);
84
+ expect(summary.successRate).toBeCloseTo(0.67, 2);
85
+ expect(summary.successful).toBe(2);
86
+ expect(summary.failed).toBe(1);
87
+ });
88
+
89
+ it('computes failuresByCategory from failureCategory field', () => {
90
+ const results: PainRecordResult[] = [
91
+ { ...makeResult(1, 'failed', [], []), failureCategory: 'runtime_unavailable' },
92
+ { ...makeResult(2, 'failed', [], []), failureCategory: 'runtime_unavailable' },
93
+ { ...makeResult(3, 'failed', [], []), failureCategory: 'output_invalid' },
94
+ ];
95
+ const summary = computeUatSummary(results, ws);
96
+ expect(summary.failuresByCategory).toEqual({
97
+ runtime_unavailable: 2,
98
+ output_invalid: 1,
99
+ });
100
+ });
101
+
102
+ it('ledgerConsistencyOk true when all audits are ok', () => {
103
+ const results: PainRecordResult[] = [
104
+ makeResult(1, 'succeeded', ['c1'], ['l1'], 100, 'ok'),
105
+ makeResult(2, 'succeeded', ['c2'], ['l2'], 100, 'ok'),
106
+ ];
107
+ const summary = computeUatSummary(results, ws);
108
+ expect(summary.ledgerConsistencyOk).toBe(true);
109
+ });
110
+
111
+ it('ledgerConsistencyOk false when any audit fails', () => {
112
+ const results: PainRecordResult[] = [
113
+ makeResult(1, 'succeeded', ['c1'], ['l1'], 100, 'ok'),
114
+ makeResult(2, 'succeeded', ['c2'], ['l2'], 100, 'error'),
115
+ ];
116
+ const summary = computeUatSummary(results, ws);
117
+ expect(summary.ledgerConsistencyOk).toBe(false);
118
+ });
119
+
120
+ it('allHaveCandidates false when any candidateIds empty', () => {
121
+ const results: PainRecordResult[] = [
122
+ makeResult(1, 'succeeded', ['c1'], ['l1']),
123
+ makeResult(2, 'succeeded', [], ['l2']),
124
+ ];
125
+ const summary = computeUatSummary(results, ws);
126
+ expect(summary.allHaveCandidates).toBe(false);
127
+ });
128
+
129
+ it('allHaveLedger false when any ledgerEntryIds empty', () => {
130
+ const results: PainRecordResult[] = [
131
+ makeResult(1, 'succeeded', ['c1'], ['l1']),
132
+ makeResult(2, 'succeeded', ['c2'], []),
133
+ ];
134
+ const summary = computeUatSummary(results, ws);
135
+ expect(summary.allHaveLedger).toBe(false);
136
+ });
137
+
138
+ it('p50LatencyMs and p95LatencyMs computed from wallTimeMs', () => {
139
+ const results: PainRecordResult[] = [
140
+ makeResult(1, 'succeeded', ['c1'], ['l1'], 100),
141
+ makeResult(2, 'succeeded', ['c2'], ['l2'], 200),
142
+ makeResult(3, 'succeeded', ['c3'], ['l3'], 300),
143
+ makeResult(4, 'succeeded', ['c4'], ['l4'], 400),
144
+ ];
145
+ const summary = computeUatSummary(results, ws);
146
+ expect(summary.p50LatencyMs).toBeDefined();
147
+ expect(summary.p95LatencyMs).toBeDefined();
148
+ expect(summary.p50LatencyMs).toBeLessThanOrEqual(summary.p95LatencyMs!);
149
+ });
150
+
151
+ it('includes perRun in summary', () => {
152
+ const results: PainRecordResult[] = [makeResult(1, 'succeeded', ['c1'], ['l1'])];
153
+ const summary = computeUatSummary(results, ws);
154
+ expect(summary.perRun).toHaveLength(1);
155
+ expect(summary.perRun[0].iteration).toBe(1);
156
+ });
157
+
158
+ it('generatedAt is ISO string', () => {
159
+ const summary = computeUatSummary([], ws);
160
+ expect(summary.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
161
+ });
162
+ });
163
+
164
+ // ── shouldExitWithError ─────────────────────────────────────────────────────
165
+
166
+ describe('shouldExitWithError', () => {
167
+ const ws = '/tmp/test-workspace';
168
+
169
+ it('returns false when all checks pass and minSuccessRate=1.0', () => {
170
+ const summary = makeSummary(ws, 5, 5, true, true, true);
171
+ expect(shouldExitWithError(summary, 1.0)).toBe(false);
172
+ });
173
+
174
+ it('returns true when successRate below threshold', () => {
175
+ const summary = makeSummary(ws, 5, 3, true, true, true);
176
+ expect(shouldExitWithError(summary, 1.0)).toBe(true);
177
+ expect(shouldExitWithError(summary, 0.5)).toBe(false);
178
+ });
179
+
180
+ it('returns true when ledgerConsistencyOk is false', () => {
181
+ const summary = makeSummary(ws, 5, 5, false, true, true);
182
+ expect(shouldExitWithError(summary, 1.0)).toBe(true);
183
+ });
184
+
185
+ it('returns true when allHaveCandidates is false', () => {
186
+ const summary = makeSummary(ws, 5, 5, true, false, true);
187
+ expect(shouldExitWithError(summary, 1.0)).toBe(true);
188
+ });
189
+
190
+ it('returns true when allHaveLedger is false', () => {
191
+ const summary = makeSummary(ws, 5, 5, true, true, false);
192
+ expect(shouldExitWithError(summary, 1.0)).toBe(true);
193
+ });
194
+
195
+ it('default minSuccessRate is 1.0', () => {
196
+ const summary = makeSummary(ws, 5, 5, true, true, true);
197
+ expect(shouldExitWithError(summary)).toBe(false);
198
+ expect(shouldExitWithError(summary, 1.0)).toBe(false);
199
+ });
200
+ });
201
+
202
+ // ── pd CLI invocation (Windows compatibility) ───────────────────────────────
203
+
204
+ describe('pd CLI invocation (Windows compatibility)', () => {
205
+ beforeEach(() => {
206
+ vi.clearAllMocks();
207
+ mockExecFileSync.mockReset();
208
+ mockExistsSync.mockReset();
209
+ mockExistsSync.mockReturnValue(true);
210
+ mockExecFileSync.mockReturnValue(JSON.stringify({
211
+ status: 'succeeded',
212
+ painId: 'pain-test-1',
213
+ taskId: 'task-001',
214
+ runId: 'run-001',
215
+ artifactId: 'art-001',
216
+ candidateIds: ['c1'],
217
+ ledgerEntryIds: ['l1'],
218
+ latencyMs: 100,
219
+ }));
220
+ });
221
+
222
+ it('uses process.execPath (not npx) to invoke pd CLI', async () => {
223
+ vi.resetModules();
224
+ mockExistsSync.mockImplementation((p: string) => String(p).endsWith('index.js'));
225
+
226
+ const { runUatIteration } = await import('../../src/commands/runtime-uat.js');
227
+ runUatIteration({ iteration: 1, reason: 'test', workspace: '/test/ws' });
228
+
229
+ // First call to execFileSync should use process.execPath, not 'npx'
230
+ const firstCall = mockExecFileSync.mock.calls[0] as [string, string[]];
231
+ expect(firstCall[0]).toBe(process.execPath);
232
+ expect(firstCall[0]).not.toBe('npx');
233
+ });
234
+
235
+ it('passes --workspace after subcommand args', async () => {
236
+ vi.resetModules();
237
+ mockExistsSync.mockImplementation((p: string) => String(p).endsWith('index.js'));
238
+
239
+ const { runUatIteration } = await import('../../src/commands/runtime-uat.js');
240
+ runUatIteration({ iteration: 1, reason: 'test', workspace: '/test/ws' });
241
+
242
+ const firstCall = mockExecFileSync.mock.calls[0] as [string, string[]];
243
+ const args = firstCall[1];
244
+ // Format: [cliPath, 'pain', 'record', ..., '--workspace', '/test/ws']
245
+ const wsIdx = args.indexOf('--workspace');
246
+ const painIdx = args.indexOf('pain');
247
+ expect(wsIdx).toBeGreaterThan(painIdx);
248
+ expect(args[wsIdx + 1]).toBe('/test/ws');
249
+ });
250
+
251
+ it('captures stdout on error with non-zero exit', async () => {
252
+ vi.resetModules();
253
+ // Allow findPdCliPath to succeed (mock existsSync to return true)
254
+ mockExistsSync.mockReturnValue(true);
255
+ const error = new Error('pd CLI spawn error') as Error & { stdout?: string; stderr?: string; code?: string };
256
+ error.stdout = '{"status":"failed","error":"command failed"}';
257
+ error.code = 'ENOENT';
258
+ mockExecFileSync.mockImplementation(() => { throw error; });
259
+
260
+ const { runUatIteration } = await import('../../src/commands/runtime-uat.js');
261
+ const result = runUatIteration({ iteration: 1, reason: 'test', workspace: '/test/ws' });
262
+
263
+ // ENOENT from spawn means "process not found" - findPdCliPath error is thrown
264
+ // This is the correct behavior: if execFileSync can't spawn the process, findPdCliPath
265
+ // threw first (meaning the CLI wasn't found at the expected path)
266
+ expect(result.status).toBe('script_error');
267
+ expect(result.error).toContain('not found');
268
+ });
269
+
270
+ it('throws when pd CLI binary not found', async () => {
271
+ vi.resetModules();
272
+ mockExistsSync.mockReturnValue(false);
273
+
274
+ const { runUatIteration } = await import('../../src/commands/runtime-uat.js');
275
+ const result = runUatIteration({ iteration: 1, reason: 'test', workspace: '/test/ws' });
276
+
277
+ expect(result.status).toBe('script_error');
278
+ expect(result.error).toContain('not found');
279
+ });
280
+
281
+ it('passes CLI path as first argument to process.execPath', async () => {
282
+ vi.resetModules();
283
+ mockExistsSync.mockImplementation((p: string) => String(p).endsWith('index.js'));
284
+
285
+ const { runUatIteration } = await import('../../src/commands/runtime-uat.js');
286
+ runUatIteration({ iteration: 1, reason: 'test', workspace: '/test/ws' });
287
+
288
+ const firstCall = mockExecFileSync.mock.calls[0] as [string, string[]];
289
+ const args = firstCall[1];
290
+ expect(args[0]).toMatch(/index\.js$/);
291
+ expect(firstCall[0]).toBe(process.execPath);
292
+ });
293
+
294
+ it('appends --workspace and workspace path to end of subcommand args', async () => {
295
+ vi.resetModules();
296
+ mockExistsSync.mockImplementation((p: string) => String(p).endsWith('index.js'));
297
+
298
+ const { runUatIteration } = await import('../../src/commands/runtime-uat.js');
299
+ runUatIteration({ iteration: 1, reason: 'test', workspace: '/custom/path' });
300
+
301
+ const firstCall = mockExecFileSync.mock.calls[0] as [string, string[]];
302
+ const args = firstCall[1];
303
+ const wsIdx = args.lastIndexOf('--workspace');
304
+ expect(wsIdx).toBe(args.length - 2);
305
+ expect(args[wsIdx + 1]).toBe('/custom/path');
306
+ });
307
+ });
308
+
309
+ // ── handleRuntimeUat guard rails ─────────────────────────────────────────────
310
+
311
+ describe('handleRuntimeUat', () => {
312
+ beforeEach(() => {
313
+ vi.clearAllMocks();
314
+ mockExecFileSync.mockReset();
315
+ mockExistsSync.mockReturnValue(true);
316
+ });
317
+
318
+ it('exits 1 when --workspace is missing', async () => {
319
+ const { handleRuntimeUat } = await import('../../src/commands/runtime-uat.js');
320
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code: number) => {
321
+ throw new Error(`exit:${code}`);
322
+ }) as (code: number) => never);
323
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
324
+
325
+ await expect(handleRuntimeUat({})).rejects.toThrow('exit:1');
326
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--workspace'));
327
+
328
+ exitSpy.mockRestore();
329
+ errorSpy.mockRestore();
330
+ });
331
+
332
+ it('exits 1 when MINIMAX_CN_API_KEY is not set', async () => {
333
+ delete process.env.MINIMAX_CN_API_KEY;
334
+ const { handleRuntimeUat } = await import('../../src/commands/runtime-uat.js');
335
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code: number) => {
336
+ throw new Error(`exit:${code}`);
337
+ }) as (code: number) => never);
338
+ vi.spyOn(console, 'error').mockImplementation(() => {});
339
+
340
+ await expect(handleRuntimeUat({ workspace: '/tmp/test-ws' })).rejects.toThrow('exit:1');
341
+ exitSpy.mockRestore();
342
+ });
343
+ });
344
+
345
+ // ── Helpers ──────────────────────────────────────────────────────────────────
346
+
347
+ function makeResult(
348
+ iteration: number,
349
+ status: string,
350
+ candidateIds: string[],
351
+ ledgerEntryIds: string[],
352
+ wallTimeMs = 1000,
353
+ auditStatus = 'ok',
354
+ failureCategory?: string,
355
+ ): PainRecordResult {
356
+ return {
357
+ iteration,
358
+ painId: status === 'succeeded' ? `pain-${iteration}` : undefined,
359
+ taskId: status === 'succeeded' ? `task-${iteration}` : undefined,
360
+ runId: status === 'succeeded' ? `run-${iteration}` : undefined,
361
+ artifactId: status === 'succeeded' ? `art-${iteration}` : undefined,
362
+ candidateIds,
363
+ ledgerEntryIds,
364
+ status,
365
+ failureCategory,
366
+ wallTimeMs,
367
+ auditStatus,
368
+ };
369
+ }
370
+
371
+ function makeSummary(
372
+ workspace: string,
373
+ totalRuns: number,
374
+ successful: number,
375
+ ledgerConsistencyOk: boolean,
376
+ allHaveCandidates: boolean,
377
+ allHaveLedger: boolean,
378
+ ): import('../../src/commands/runtime-uat.js').UatSummary {
379
+ const results: PainRecordResult[] = Array.from({ length: totalRuns }, (_, i) =>
380
+ makeResult(i + 1, i < successful ? 'succeeded' : 'failed', ['c1'], ['l1'])
381
+ );
382
+ return {
383
+ generatedAt: new Date().toISOString(),
384
+ workspace,
385
+ totalRuns,
386
+ successful,
387
+ failed: totalRuns - successful,
388
+ successRate: Number((successful / totalRuns).toFixed(2)),
389
+ p50LatencyMs: 500,
390
+ p95LatencyMs: 900,
391
+ failuresByCategory: {},
392
+ ledgerConsistencyOk,
393
+ allHaveCandidates,
394
+ allHaveLedger,
395
+ perRun: results,
396
+ };
397
+ }
@@ -0,0 +1,262 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { handleRuntimeProbe, type RuntimeProbeOptions } from '../../src/commands/runtime.js';
3
+
4
+ // Mock probeRuntime
5
+ vi.mock('@principles/core/runtime-v2', () => ({
6
+ probeRuntime: vi.fn().mockResolvedValue({
7
+ runtimeKind: 'openclaw-cli',
8
+ health: {
9
+ healthy: true,
10
+ degraded: false,
11
+ warnings: [],
12
+ lastCheckedAt: '2026-04-24T00:00:00.000Z',
13
+ },
14
+ capabilities: {
15
+ supportsStructuredJsonOutput: true,
16
+ supportsToolUse: false,
17
+ supportsWorkingDirectory: false,
18
+ supportsModelSelection: false,
19
+ supportsLongRunningSessions: false,
20
+ supportsCancellation: true,
21
+ supportsArtifactWriteBack: false,
22
+ supportsConcurrentRuns: false,
23
+ supportsStreaming: false,
24
+ },
25
+ }),
26
+ resolveRuntimeConfig: vi.fn().mockReturnValue({
27
+ runtimeKind: 'pi-ai',
28
+ provider: 'test-provider',
29
+ model: 'test-model',
30
+ apiKeyEnv: 'TEST_KEY',
31
+ timeoutMs: 300000,
32
+ agentId: 'main',
33
+ }),
34
+ isRuntimeConfigError: vi.fn().mockReturnValue(false),
35
+ PDRuntimeError: class PDRuntimeError extends Error {
36
+ constructor(public category: string, message: string) {
37
+ super(message);
38
+ this.name = 'PDRuntimeError';
39
+ }
40
+ },
41
+ }));
42
+
43
+ describe('pd runtime probe', () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ });
47
+
48
+ it('HG-01: --runtime openclaw-cli --openclaw-local outputs health + capabilities table', async () => {
49
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
50
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
51
+
52
+ await handleRuntimeProbe({
53
+ runtime: 'openclaw-cli',
54
+ openclawLocal: true,
55
+ json: false,
56
+ } as RuntimeProbeOptions);
57
+
58
+ // Should output health section
59
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Runtime:'));
60
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('healthy:'));
61
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Capabilities:'));
62
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
63
+
64
+ consoleSpy.mockRestore();
65
+ exitSpy.mockRestore();
66
+ });
67
+
68
+ it('HG-01: --runtime openclaw-cli --openclaw-gateway outputs health + capabilities table', async () => {
69
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
70
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
71
+
72
+ await handleRuntimeProbe({
73
+ runtime: 'openclaw-cli',
74
+ openclawGateway: true,
75
+ json: false,
76
+ } as RuntimeProbeOptions);
77
+
78
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Runtime:'));
79
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
80
+
81
+ consoleSpy.mockRestore();
82
+ exitSpy.mockRestore();
83
+ });
84
+
85
+ it('CLI-03: --json flag outputs structured JSON with health + capabilities', async () => {
86
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
87
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
88
+
89
+ await handleRuntimeProbe({
90
+ runtime: 'openclaw-cli',
91
+ openclawLocal: true,
92
+ json: true,
93
+ } as RuntimeProbeOptions);
94
+
95
+ const jsonOutput = consoleSpy.mock.calls.find(call => {
96
+ try {
97
+ JSON.parse(call[0] as string);
98
+ return true;
99
+ } catch { return false; }
100
+ });
101
+ expect(jsonOutput).toBeDefined();
102
+ const parsed = JSON.parse((jsonOutput as [string])[0]);
103
+ expect(parsed.status).toBe('succeeded');
104
+ expect(parsed.runtimeKind).toBe('openclaw-cli');
105
+ expect(parsed.health).toBeDefined();
106
+ expect(parsed.capabilities).toBeDefined();
107
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
108
+
109
+ consoleSpy.mockRestore();
110
+ exitSpy.mockRestore();
111
+ });
112
+
113
+ it('CLI-03: --json with healthy=false outputs status=failed and exits 1', async () => {
114
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
115
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
116
+
117
+ const { probeRuntime } = await import('@principles/core/runtime-v2');
118
+ vi.mocked(probeRuntime).mockResolvedValueOnce({
119
+ runtimeKind: 'openclaw-cli',
120
+ health: {
121
+ healthy: false,
122
+ degraded: false,
123
+ warnings: ['openclaw binary not found'],
124
+ lastCheckedAt: '2026-04-24T00:00:00.000Z',
125
+ },
126
+ capabilities: {
127
+ supportsStructuredJsonOutput: true,
128
+ supportsToolUse: false,
129
+ supportsWorkingDirectory: false,
130
+ supportsModelSelection: false,
131
+ supportsLongRunningSessions: false,
132
+ supportsCancellation: true,
133
+ supportsArtifactWriteBack: false,
134
+ supportsConcurrentRuns: false,
135
+ supportsStreaming: false,
136
+ },
137
+ });
138
+
139
+ await handleRuntimeProbe({
140
+ runtime: 'openclaw-cli',
141
+ openclawLocal: true,
142
+ json: true,
143
+ } as RuntimeProbeOptions);
144
+
145
+ const jsonOutput = consoleSpy.mock.calls.find(call => {
146
+ try {
147
+ const p = JSON.parse(call[0] as string);
148
+ return p.status !== undefined;
149
+ } catch { return false; }
150
+ });
151
+ expect(jsonOutput).toBeDefined();
152
+ const parsed = JSON.parse((jsonOutput as [string])[0]);
153
+ expect(parsed.status).toBe('failed');
154
+ expect(exitSpy).toHaveBeenCalledWith(1);
155
+
156
+ consoleSpy.mockRestore();
157
+ exitSpy.mockRestore();
158
+ });
159
+
160
+ it('CLI-03: --json with healthy=true degraded=true outputs status=degraded', async () => {
161
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
162
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
163
+
164
+ const { probeRuntime } = await import('@principles/core/runtime-v2');
165
+ vi.mocked(probeRuntime).mockResolvedValueOnce({
166
+ runtimeKind: 'openclaw-cli',
167
+ health: {
168
+ healthy: true,
169
+ degraded: true,
170
+ warnings: ['openclaw version is outdated'],
171
+ lastCheckedAt: '2026-04-24T00:00:00.000Z',
172
+ },
173
+ capabilities: {
174
+ supportsStructuredJsonOutput: true,
175
+ supportsToolUse: false,
176
+ supportsWorkingDirectory: false,
177
+ supportsModelSelection: false,
178
+ supportsLongRunningSessions: false,
179
+ supportsCancellation: true,
180
+ supportsArtifactWriteBack: false,
181
+ supportsConcurrentRuns: false,
182
+ supportsStreaming: false,
183
+ },
184
+ });
185
+
186
+ await handleRuntimeProbe({
187
+ runtime: 'openclaw-cli',
188
+ openclawLocal: true,
189
+ json: true,
190
+ } as RuntimeProbeOptions);
191
+
192
+ const jsonOutput = consoleSpy.mock.calls.find(call => {
193
+ try {
194
+ const p = JSON.parse(call[0] as string);
195
+ return p.status !== undefined;
196
+ } catch { return false; }
197
+ });
198
+ expect(jsonOutput).toBeDefined();
199
+ const parsed = JSON.parse((jsonOutput as [string])[0]);
200
+ expect(parsed.status).toBe('degraded');
201
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
202
+
203
+ consoleSpy.mockRestore();
204
+ exitSpy.mockRestore();
205
+ });
206
+
207
+ it('HG-03: --runtime openclaw-cli without mode flag exits with error', async () => {
208
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
209
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
210
+
211
+ await handleRuntimeProbe({
212
+ runtime: 'openclaw-cli',
213
+ json: false,
214
+ } as RuntimeProbeOptions);
215
+
216
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
217
+ 'error: --openclaw-local or --openclaw-gateway is required for --runtime openclaw-cli'
218
+ );
219
+ expect(exitSpy).toHaveBeenCalledWith(1);
220
+
221
+ consoleErrorSpy.mockRestore();
222
+ exitSpy.mockRestore();
223
+ });
224
+
225
+ it('HG-03: both --openclaw-local and --openclaw-gateway exits with error', async () => {
226
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
227
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
228
+
229
+ await handleRuntimeProbe({
230
+ runtime: 'openclaw-cli',
231
+ openclawLocal: true,
232
+ openclawGateway: true,
233
+ json: false,
234
+ } as RuntimeProbeOptions);
235
+
236
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
237
+ 'error: --openclaw-local and --openclaw-gateway are mutually exclusive'
238
+ );
239
+ expect(exitSpy).toHaveBeenCalledWith(1);
240
+
241
+ consoleErrorSpy.mockRestore();
242
+ exitSpy.mockRestore();
243
+ });
244
+
245
+ it('HG-01: --runtime other-than-openclaw-cli exits with error', async () => {
246
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
247
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
248
+
249
+ await handleRuntimeProbe({
250
+ runtime: 'test-double',
251
+ json: false,
252
+ } as RuntimeProbeOptions);
253
+
254
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
255
+ expect.stringContaining("only supports --runtime openclaw-cli")
256
+ );
257
+ expect(exitSpy).toHaveBeenCalledWith(1);
258
+
259
+ consoleErrorSpy.mockRestore();
260
+ exitSpy.mockRestore();
261
+ });
262
+ });