@jevitate/cli 0.1.0 → 0.3.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 (410) hide show
  1. package/README.md +23 -0
  2. package/dist/ai-cli.d.ts +31 -11
  3. package/dist/ai-cli.js +131 -88
  4. package/dist/ai-cli.js.map +1 -1
  5. package/dist/author-journey-cli.d.ts +4 -0
  6. package/dist/author-journey-cli.js +111 -0
  7. package/dist/author-journey-cli.js.map +1 -0
  8. package/dist/bin.js +60349 -16477
  9. package/dist/bin.js.map +1 -1
  10. package/dist/browser-run-options.d.ts +89 -0
  11. package/dist/browser-run-options.js +175 -0
  12. package/dist/browser-run-options.js.map +1 -0
  13. package/dist/build-info.generated.d.ts +2 -0
  14. package/dist/build-info.generated.js +10 -0
  15. package/dist/build-info.generated.js.map +1 -0
  16. package/dist/campaign-api.d.ts +151 -0
  17. package/dist/campaign-api.js +446 -0
  18. package/dist/campaign-api.js.map +1 -0
  19. package/dist/campaign-cli.d.ts +6 -0
  20. package/dist/campaign-cli.js +66 -0
  21. package/dist/campaign-cli.js.map +1 -0
  22. package/dist/check-api.d.ts +5 -0
  23. package/dist/check-api.js +234 -0
  24. package/dist/check-api.js.map +1 -0
  25. package/dist/check-budget.d.ts +26 -0
  26. package/dist/check-budget.js +90 -0
  27. package/dist/check-budget.js.map +1 -0
  28. package/dist/check-cli.d.ts +31 -0
  29. package/dist/check-cli.js +109 -0
  30. package/dist/check-cli.js.map +1 -0
  31. package/dist/check-execute.d.ts +29 -0
  32. package/dist/check-execute.js +420 -0
  33. package/dist/check-execute.js.map +1 -0
  34. package/dist/check-plan.d.ts +118 -0
  35. package/dist/check-plan.js +516 -0
  36. package/dist/check-plan.js.map +1 -0
  37. package/dist/check-suite.d.ts +184 -0
  38. package/dist/check-suite.js +540 -0
  39. package/dist/check-suite.js.map +1 -0
  40. package/dist/check-types.d.ts +181 -0
  41. package/dist/check-types.js +35 -0
  42. package/dist/check-types.js.map +1 -0
  43. package/dist/cli-args.d.ts +26 -0
  44. package/dist/cli-args.js +50 -0
  45. package/dist/cli-args.js.map +1 -0
  46. package/dist/cli-output.d.ts +106 -0
  47. package/dist/cli-output.js +563 -0
  48. package/dist/cli-output.js.map +1 -0
  49. package/dist/cli-reference.d.ts +3 -0
  50. package/dist/cli-reference.js +81 -0
  51. package/dist/cli-reference.js.map +1 -0
  52. package/dist/cli-refusal.d.ts +12 -0
  53. package/dist/cli-refusal.js +52 -0
  54. package/dist/cli-refusal.js.map +1 -0
  55. package/dist/cli-shared.d.ts +340 -0
  56. package/dist/cli-shared.js +537 -0
  57. package/dist/cli-shared.js.map +1 -0
  58. package/dist/cli-surface.d.ts +33 -0
  59. package/dist/cli-surface.js +48 -0
  60. package/dist/cli-surface.js.map +1 -0
  61. package/dist/conversation-options.d.ts +15 -0
  62. package/dist/conversation-options.js +10 -0
  63. package/dist/conversation-options.js.map +1 -0
  64. package/dist/credentials-file.d.ts +15 -0
  65. package/dist/credentials-file.js +56 -0
  66. package/dist/credentials-file.js.map +1 -0
  67. package/dist/data-dir.d.ts +1 -1
  68. package/dist/data-dir.js +1 -1
  69. package/dist/defect-evidence.d.ts +141 -0
  70. package/dist/defect-evidence.js +505 -0
  71. package/dist/defect-evidence.js.map +1 -0
  72. package/dist/demo-aspect-api.d.ts +209 -0
  73. package/dist/demo-aspect-api.js +398 -0
  74. package/dist/demo-aspect-api.js.map +1 -0
  75. package/dist/demo-aspect-cli.d.ts +3 -0
  76. package/dist/demo-aspect-cli.js +243 -0
  77. package/dist/demo-aspect-cli.js.map +1 -0
  78. package/dist/demo-capture.d.ts +142 -0
  79. package/dist/demo-capture.js +537 -0
  80. package/dist/demo-capture.js.map +1 -0
  81. package/dist/engine.d.ts +35 -0
  82. package/dist/engine.js +25 -0
  83. package/dist/engine.js.map +1 -0
  84. package/dist/environments.d.ts +155 -0
  85. package/dist/environments.js +364 -0
  86. package/dist/environments.js.map +1 -0
  87. package/dist/exit-codes.d.ts +46 -0
  88. package/dist/exit-codes.js +91 -0
  89. package/dist/exit-codes.js.map +1 -0
  90. package/dist/explore-adversarial.d.ts +144 -0
  91. package/dist/explore-adversarial.js +227 -0
  92. package/dist/explore-adversarial.js.map +1 -0
  93. package/dist/explore-api.d.ts +7 -187
  94. package/dist/explore-api.js +7 -301
  95. package/dist/explore-api.js.map +1 -1
  96. package/dist/explore-author.d.ts +79 -0
  97. package/dist/explore-author.js +86 -0
  98. package/dist/explore-author.js.map +1 -0
  99. package/dist/explore-cli.d.ts +7 -0
  100. package/dist/explore-cli.js +1199 -0
  101. package/dist/explore-cli.js.map +1 -0
  102. package/dist/explore-coverage.d.ts +173 -0
  103. package/dist/explore-coverage.js +246 -0
  104. package/dist/explore-coverage.js.map +1 -0
  105. package/dist/explore-feature.d.ts +125 -0
  106. package/dist/explore-feature.js +218 -0
  107. package/dist/explore-feature.js.map +1 -0
  108. package/dist/explore-goal.d.ts +271 -0
  109. package/dist/explore-goal.js +417 -0
  110. package/dist/explore-goal.js.map +1 -0
  111. package/dist/explore-shared.d.ts +177 -0
  112. package/dist/explore-shared.js +149 -0
  113. package/dist/explore-shared.js.map +1 -0
  114. package/dist/explore-specs.d.ts +53 -0
  115. package/dist/explore-specs.js +333 -0
  116. package/dist/explore-specs.js.map +1 -0
  117. package/dist/findings-filing.d.ts +64 -0
  118. package/dist/findings-filing.js +108 -0
  119. package/dist/findings-filing.js.map +1 -0
  120. package/dist/fixture-auth.d.ts +63 -0
  121. package/dist/fixture-auth.js +95 -0
  122. package/dist/fixture-auth.js.map +1 -0
  123. package/dist/fixture-cli.d.ts +99 -0
  124. package/dist/fixture-cli.js +292 -0
  125. package/dist/fixture-cli.js.map +1 -0
  126. package/dist/github-issue-filer.d.ts +62 -0
  127. package/dist/github-issue-filer.js +189 -0
  128. package/dist/github-issue-filer.js.map +1 -0
  129. package/dist/host-health-run.d.ts +25 -0
  130. package/dist/host-health-run.js +33 -0
  131. package/dist/host-health-run.js.map +1 -0
  132. package/dist/http-5xx-outcome.d.ts +6 -0
  133. package/dist/http-5xx-outcome.js +39 -0
  134. package/dist/http-5xx-outcome.js.map +1 -0
  135. package/dist/inbox-cli.d.ts +7 -0
  136. package/dist/inbox-cli.js +197 -0
  137. package/dist/inbox-cli.js.map +1 -0
  138. package/dist/index.d.ts +1 -0
  139. package/dist/index.js +1 -0
  140. package/dist/index.js.map +1 -1
  141. package/dist/init-cli.d.ts +4 -0
  142. package/dist/init-cli.js +156 -0
  143. package/dist/init-cli.js.map +1 -0
  144. package/dist/init-keys.d.ts +30 -2
  145. package/dist/init-keys.js +19 -5
  146. package/dist/init-keys.js.map +1 -1
  147. package/dist/init-next-steps.d.ts +35 -0
  148. package/dist/init-next-steps.js +79 -0
  149. package/dist/init-next-steps.js.map +1 -0
  150. package/dist/invariants-file.d.ts +34 -0
  151. package/dist/invariants-file.js +68 -0
  152. package/dist/invariants-file.js.map +1 -0
  153. package/dist/invariants-validate.d.ts +30 -0
  154. package/dist/invariants-validate.js +105 -0
  155. package/dist/invariants-validate.js.map +1 -0
  156. package/dist/journey-annotate-api.d.ts +76 -0
  157. package/dist/journey-annotate-api.js +269 -0
  158. package/dist/journey-annotate-api.js.map +1 -0
  159. package/dist/journey-annotate-cli.d.ts +9 -0
  160. package/dist/journey-annotate-cli.js +181 -0
  161. package/dist/journey-annotate-cli.js.map +1 -0
  162. package/dist/journey-api.d.ts +104 -2
  163. package/dist/journey-api.js +163 -20
  164. package/dist/journey-api.js.map +1 -1
  165. package/dist/journey-cli.d.ts +4 -0
  166. package/dist/journey-cli.js +411 -0
  167. package/dist/journey-cli.js.map +1 -0
  168. package/dist/journey-demo-api.d.ts +114 -0
  169. package/dist/journey-demo-api.js +405 -0
  170. package/dist/journey-demo-api.js.map +1 -0
  171. package/dist/journey-demo-cli.d.ts +10 -0
  172. package/dist/journey-demo-cli.js +158 -0
  173. package/dist/journey-demo-cli.js.map +1 -0
  174. package/dist/journey-prefix.d.ts +194 -0
  175. package/dist/journey-prefix.js +279 -0
  176. package/dist/journey-prefix.js.map +1 -0
  177. package/dist/key-report.d.ts +49 -0
  178. package/dist/key-report.js +101 -0
  179. package/dist/key-report.js.map +1 -0
  180. package/dist/kill-signal.d.ts +193 -0
  181. package/dist/kill-signal.js +283 -0
  182. package/dist/kill-signal.js.map +1 -0
  183. package/dist/launch-armed.d.ts +37 -0
  184. package/dist/launch-armed.js +31 -0
  185. package/dist/launch-armed.js.map +1 -0
  186. package/dist/ledger-api.d.ts +106 -0
  187. package/dist/ledger-api.js +239 -0
  188. package/dist/ledger-api.js.map +1 -0
  189. package/dist/ledger-cli.d.ts +18 -0
  190. package/dist/ledger-cli.js +107 -0
  191. package/dist/ledger-cli.js.map +1 -0
  192. package/dist/load-api.d.ts +29 -11
  193. package/dist/load-api.js +23 -15
  194. package/dist/load-api.js.map +1 -1
  195. package/dist/load-cli.d.ts +4 -0
  196. package/dist/load-cli.js +97 -0
  197. package/dist/load-cli.js.map +1 -0
  198. package/dist/log-correlation.d.ts +200 -0
  199. package/dist/log-correlation.js +394 -0
  200. package/dist/log-correlation.js.map +1 -0
  201. package/dist/log-lines.d.ts +131 -0
  202. package/dist/log-lines.js +389 -0
  203. package/dist/log-lines.js.map +1 -0
  204. package/dist/log-sources.d.ts +71 -0
  205. package/dist/log-sources.js +236 -0
  206. package/dist/log-sources.js.map +1 -0
  207. package/dist/log-trace.d.ts +80 -0
  208. package/dist/log-trace.js +210 -0
  209. package/dist/log-trace.js.map +1 -0
  210. package/dist/logs-cli.d.ts +3 -0
  211. package/dist/logs-cli.js +51 -0
  212. package/dist/logs-cli.js.map +1 -0
  213. package/dist/logs-retention.d.ts +28 -0
  214. package/dist/logs-retention.js +85 -0
  215. package/dist/logs-retention.js.map +1 -0
  216. package/dist/masked-input.d.ts +22 -0
  217. package/dist/masked-input.js +99 -0
  218. package/dist/masked-input.js.map +1 -0
  219. package/dist/mcp-api.d.ts +91 -1
  220. package/dist/mcp-api.js +514 -18
  221. package/dist/mcp-api.js.map +1 -1
  222. package/dist/mcp-args.d.ts +50 -0
  223. package/dist/mcp-args.js +164 -0
  224. package/dist/mcp-args.js.map +1 -0
  225. package/dist/mcp-cli-bridge.d.ts +34 -0
  226. package/dist/mcp-cli-bridge.js +62 -0
  227. package/dist/mcp-cli-bridge.js.map +1 -0
  228. package/dist/mcp-cli-runner.d.ts +29 -0
  229. package/dist/mcp-cli-runner.js +44 -0
  230. package/dist/mcp-cli-runner.js.map +1 -0
  231. package/dist/mcp-cli-tools.d.ts +129 -0
  232. package/dist/mcp-cli-tools.js +737 -0
  233. package/dist/mcp-cli-tools.js.map +1 -0
  234. package/dist/mcp-paths.d.ts +28 -0
  235. package/dist/mcp-paths.js +76 -0
  236. package/dist/mcp-paths.js.map +1 -0
  237. package/dist/mission-actors.d.ts +40 -0
  238. package/dist/mission-actors.js +72 -0
  239. package/dist/mission-actors.js.map +1 -0
  240. package/dist/mission-api.d.ts +35 -0
  241. package/dist/mission-api.js +60 -3
  242. package/dist/mission-api.js.map +1 -1
  243. package/dist/mission-cli.d.ts +4 -0
  244. package/dist/mission-cli.js +248 -0
  245. package/dist/mission-cli.js.map +1 -0
  246. package/dist/mission-exit.d.ts +22 -0
  247. package/dist/mission-exit.js +26 -0
  248. package/dist/mission-exit.js.map +1 -0
  249. package/dist/mission-fixtures.d.ts +234 -0
  250. package/dist/mission-fixtures.js +862 -0
  251. package/dist/mission-fixtures.js.map +1 -0
  252. package/dist/mission-journal.d.ts +60 -0
  253. package/dist/mission-journal.js +115 -0
  254. package/dist/mission-journal.js.map +1 -0
  255. package/dist/mission-queue-cli.d.ts +10 -0
  256. package/dist/mission-queue-cli.js +198 -0
  257. package/dist/mission-queue-cli.js.map +1 -0
  258. package/dist/mission-queue-runner.d.ts +126 -0
  259. package/dist/mission-queue-runner.js +406 -0
  260. package/dist/mission-queue-runner.js.map +1 -0
  261. package/dist/multi-run-cli.d.ts +37 -0
  262. package/dist/multi-run-cli.js +116 -0
  263. package/dist/multi-run-cli.js.map +1 -0
  264. package/dist/multi-run.d.ts +339 -0
  265. package/dist/multi-run.js +619 -0
  266. package/dist/multi-run.js.map +1 -0
  267. package/dist/openrouter-call.d.ts +22 -0
  268. package/dist/openrouter-call.js +64 -0
  269. package/dist/openrouter-call.js.map +1 -0
  270. package/dist/profile-cli.d.ts +4 -0
  271. package/dist/profile-cli.js +57 -0
  272. package/dist/profile-cli.js.map +1 -0
  273. package/dist/program.d.ts +3 -75
  274. package/dist/program.js +123 -1921
  275. package/dist/program.js.map +1 -1
  276. package/dist/project-dir.d.ts +96 -0
  277. package/dist/project-dir.js +221 -0
  278. package/dist/project-dir.js.map +1 -0
  279. package/dist/record-api.d.ts +13 -7
  280. package/dist/record-api.js +44 -30
  281. package/dist/record-api.js.map +1 -1
  282. package/dist/record-cli.d.ts +4 -0
  283. package/dist/record-cli.js +76 -0
  284. package/dist/record-cli.js.map +1 -0
  285. package/dist/recording-cli.d.ts +4 -0
  286. package/dist/recording-cli.js +248 -0
  287. package/dist/recording-cli.js.map +1 -0
  288. package/dist/regression-api.d.ts +107 -0
  289. package/dist/regression-api.js +387 -6
  290. package/dist/regression-api.js.map +1 -1
  291. package/dist/regression-cli.d.ts +4 -0
  292. package/dist/regression-cli.js +284 -0
  293. package/dist/regression-cli.js.map +1 -0
  294. package/dist/report-api.d.ts +146 -0
  295. package/dist/report-api.js +431 -0
  296. package/dist/report-api.js.map +1 -0
  297. package/dist/report-cli.d.ts +12 -0
  298. package/dist/report-cli.js +147 -0
  299. package/dist/report-cli.js.map +1 -0
  300. package/dist/resource-preflight.d.ts +87 -0
  301. package/dist/resource-preflight.js +160 -0
  302. package/dist/resource-preflight.js.map +1 -0
  303. package/dist/result-schema.d.ts +22 -0
  304. package/dist/result-schema.js +28 -0
  305. package/dist/result-schema.js.map +1 -0
  306. package/dist/run-index.d.ts +29 -0
  307. package/dist/run-index.js +70 -0
  308. package/dist/run-index.js.map +1 -0
  309. package/dist/run-key-preflight.d.ts +42 -0
  310. package/dist/run-key-preflight.js +82 -0
  311. package/dist/run-key-preflight.js.map +1 -0
  312. package/dist/run-screenshots.d.ts +123 -0
  313. package/dist/run-screenshots.js +235 -0
  314. package/dist/run-screenshots.js.map +1 -0
  315. package/dist/runtime-env.d.ts +8 -0
  316. package/dist/runtime-env.js +23 -0
  317. package/dist/runtime-env.js.map +1 -0
  318. package/dist/secret-args.d.ts +23 -0
  319. package/dist/secret-args.js +45 -0
  320. package/dist/secret-args.js.map +1 -0
  321. package/dist/serve-cli.d.ts +4 -0
  322. package/dist/serve-cli.js +95 -0
  323. package/dist/serve-cli.js.map +1 -0
  324. package/dist/server-log-verify.d.ts +36 -0
  325. package/dist/server-log-verify.js +120 -0
  326. package/dist/server-log-verify.js.map +1 -0
  327. package/dist/session-check.d.ts +14 -0
  328. package/dist/session-check.js +44 -0
  329. package/dist/session-check.js.map +1 -0
  330. package/dist/site-cli.d.ts +4 -0
  331. package/dist/site-cli.js +127 -0
  332. package/dist/site-cli.js.map +1 -0
  333. package/dist/site-gate-cli.d.ts +28 -0
  334. package/dist/site-gate-cli.js +59 -0
  335. package/dist/site-gate-cli.js.map +1 -0
  336. package/dist/source-api.js +20 -4
  337. package/dist/source-api.js.map +1 -1
  338. package/dist/source-cli.d.ts +4 -0
  339. package/dist/source-cli.js +284 -0
  340. package/dist/source-cli.js.map +1 -0
  341. package/dist/source-run-api.d.ts +27 -1
  342. package/dist/source-run-api.js +22 -11
  343. package/dist/source-run-api.js.map +1 -1
  344. package/dist/storage-state-snapshot.d.ts +50 -0
  345. package/dist/storage-state-snapshot.js +87 -0
  346. package/dist/storage-state-snapshot.js.map +1 -0
  347. package/dist/suite-explore-options.d.ts +321 -0
  348. package/dist/suite-explore-options.js +162 -0
  349. package/dist/suite-explore-options.js.map +1 -0
  350. package/dist/target-config.d.ts +115 -0
  351. package/dist/target-config.js +225 -0
  352. package/dist/target-config.js.map +1 -0
  353. package/dist/transcript-file.d.ts +12 -0
  354. package/dist/transcript-file.js +23 -0
  355. package/dist/transcript-file.js.map +1 -0
  356. package/dist/type-fixture-file.d.ts +9 -0
  357. package/dist/type-fixture-file.js +48 -0
  358. package/dist/type-fixture-file.js.map +1 -0
  359. package/dist/ui-api.d.ts +3 -0
  360. package/dist/ui-api.js +11 -4
  361. package/dist/ui-api.js.map +1 -1
  362. package/dist/usability-capture.d.ts +43 -0
  363. package/dist/usability-capture.js +327 -0
  364. package/dist/usability-capture.js.map +1 -0
  365. package/dist/usage-config.d.ts +36 -0
  366. package/dist/usage-config.js +114 -0
  367. package/dist/usage-config.js.map +1 -0
  368. package/dist/ux-api.d.ts +3 -75
  369. package/dist/ux-api.js +3 -220
  370. package/dist/ux-api.js.map +1 -1
  371. package/dist/ux-claim-probe.d.ts +45 -0
  372. package/dist/ux-claim-probe.js +386 -0
  373. package/dist/ux-claim-probe.js.map +1 -0
  374. package/dist/ux-cli.d.ts +4 -0
  375. package/dist/ux-cli.js +109 -0
  376. package/dist/ux-cli.js.map +1 -0
  377. package/dist/ux-config.d.ts +29 -0
  378. package/dist/ux-config.js +103 -0
  379. package/dist/ux-config.js.map +1 -0
  380. package/dist/ux-evidence.d.ts +54 -0
  381. package/dist/ux-evidence.js +197 -0
  382. package/dist/ux-evidence.js.map +1 -0
  383. package/dist/ux-product.d.ts +14 -0
  384. package/dist/ux-product.js +39 -0
  385. package/dist/ux-product.js.map +1 -0
  386. package/dist/ux-review.d.ts +116 -0
  387. package/dist/ux-review.js +171 -0
  388. package/dist/ux-review.js.map +1 -0
  389. package/dist/ux-usability.d.ts +263 -0
  390. package/dist/ux-usability.js +580 -0
  391. package/dist/ux-usability.js.map +1 -0
  392. package/dist/verify-fix-api.d.ts +194 -0
  393. package/dist/verify-fix-api.js +506 -0
  394. package/dist/verify-fix-api.js.map +1 -0
  395. package/dist/verify-fix-cli.d.ts +4 -0
  396. package/dist/verify-fix-cli.js +115 -0
  397. package/dist/verify-fix-cli.js.map +1 -0
  398. package/package.json +23 -18
  399. package/skills/jevitate-ci-check/SKILL.md +77 -0
  400. package/skills/jevitate-demo/SKILL.md +61 -0
  401. package/skills/jevitate-explore/SKILL.md +96 -87
  402. package/skills/jevitate-getting-started/SKILL.md +89 -0
  403. package/skills/jevitate-load-test/SKILL.md +11 -2
  404. package/skills/jevitate-mission-scope/SKILL.md +19 -11
  405. package/skills/jevitate-record/SKILL.md +23 -5
  406. package/skills/jevitate-run-journey/SKILL.md +61 -17
  407. package/skills/jevitate-sources/SKILL.md +57 -14
  408. package/skills/jevitate-test-campaign/SKILL.md +203 -0
  409. package/skills/jevitate-ux-review/SKILL.md +106 -16
  410. package/skills/jevitate-verify-fix/SKILL.md +76 -0
@@ -0,0 +1,862 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { redactText, redactUrl } from "@jevitate/ai-core";
5
+ import { isAuthorizedExploreTarget } from "@jevitate/explore";
6
+ import { authHeaders } from "./fixture-auth.js";
7
+ import { clock } from "@jevitate/domain";
8
+ /**
9
+ * Mission fixtures (#140 declarative app setup, #144 setup/restore around missions and replays) —
10
+ * ONE capability: put the app into a known state before a mission, bind what that setup created
11
+ * (an item id, an invite URL) into the run as `${setup.<name>}`, restore the state afterwards, and
12
+ * do the same around EVERY replay (hang reproduction, `verify-fix`, regression capture) so a replay
13
+ * starts from the state the finding was made in, not whatever earlier runs left behind.
14
+ *
15
+ * Two step kinds, both operator-declared:
16
+ *
17
+ * - HTTP steps, from a fixtures JSON file (`--fixtures`, or `fixtures` in `~/.jevitate/targets.json`).
18
+ * Only to an `--allow`-listed http(s) origin; authenticated like the page is (`fixture-auth.ts`:
19
+ * a storageState localStorage bearer, its cookies, or a `--secret-field` binding). A file can
20
+ * never declare a command. A step's json/body/headers may use `${secretField.<VAR>}` (#166) —
21
+ * the run's `--secret-field` value bound to env `<VAR>`, so a login step can authenticate; it
22
+ * is put on the wire only (never in a URL, the step log, the result or the Recording).
23
+ * - Shell hooks, from the operator's own CLI flags only (`--before <cmd>` / `--after <cmd>`, and
24
+ * only with `--allow-shell-hooks`). Never model-chosen, never read from a file a run wrote. Exit
25
+ * codes are recorded; stdout is never persisted (it may carry `{vars, secret}`), stderr only
26
+ * redacted and clipped.
27
+ *
28
+ * Fail closed: a setup that fails, times out, or leaves a `${setup.x}` reference unresolved ends
29
+ * the run `inconclusive` (`fixture setup failed: …`, a configuration error) — a mission never runs
30
+ * on unknown state. Secret outputs join the run's redaction set and may only be used inside the
31
+ * fixture's own requests, never in text a model sees.
32
+ */
33
+ export const SETUP_REF = /\$\{setup\.([A-Za-z_][A-Za-z0-9_]*)\}/g;
34
+ /**
35
+ * `${secretField.<VAR>}` (#166): a run's `--secret-field` value (bound to env `<VAR>`), allowed only
36
+ * in a step's `json`/`body`/`headers` — so a login step can authenticate. Substituted into the
37
+ * outgoing request only: never into a URL, the step log, the result or the Recording.
38
+ */
39
+ export const SECRET_FIELD_REF = /\$\{secretField\.([A-Za-z_][A-Za-z0-9_]*)\}/g;
40
+ /** Either reference, matched in ONE pass (a substituted value is never re-expanded). */
41
+ const STEP_REF = /\$\{(setup|secretField)\.([A-Za-z_][A-Za-z0-9_]*)\}/g;
42
+ const OUTPUT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
43
+ /** A fixture identity's name (#243) — the same shape as a persona/actor name. */
44
+ const IDENTITY_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
45
+ const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
46
+ /** Keys that would make a fixtures file run a command: refused anywhere in a step. */
47
+ const COMMAND_KEYS = new Set(["command", "cmd", "shell", "exec", "run", "script", "argv", "spawn", "before", "after"]);
48
+ const STEP_KEYS = new Set(["name", "method", "url", "headers", "json", "body", "auth", "expectStatus", "outputs", "secretOutputs", "timeoutMs"]);
49
+ /** Literal credentials would be persisted with the spec: auth comes from the run's session instead. */
50
+ const CREDENTIAL_HEADERS = new Set(["authorization", "cookie", "proxy-authorization", "x-api-key"]);
51
+ const DEFAULT_STEP_TIMEOUT_MS = 30_000;
52
+ const DEFAULT_HOOK_TIMEOUT_MS = 60_000;
53
+ const MAX_HOOK_STDOUT = 64 * 1024;
54
+ const MAX_LOGGED_OUTPUT = 1_000;
55
+ export class FixtureSpecError extends Error {
56
+ code = "E_FIXTURE_SPEC";
57
+ constructor(message) {
58
+ super(message);
59
+ this.name = "FixtureSpecError";
60
+ }
61
+ }
62
+ /** Setup failed: the run must end `inconclusive` (a configuration error) without touching the app. */
63
+ export class FixtureSetupError extends Error {
64
+ code = "E_FIXTURE_SETUP";
65
+ constructor(detail) {
66
+ super(`fixture setup failed: ${detail}`);
67
+ this.name = "FixtureSetupError";
68
+ }
69
+ }
70
+ function isRecord(v) {
71
+ return v !== null && typeof v === "object" && !Array.isArray(v);
72
+ }
73
+ /** Every `${setup.x}` name referenced in `text`. */
74
+ export function referencedNames(text) {
75
+ return [...text.matchAll(SETUP_REF)].map((m) => m[1]);
76
+ }
77
+ /**
78
+ * Every `--secret-field` env variable a spec needs (#166): the `auth: {from: "secretField"}` names
79
+ * and every `${secretField.<VAR>}` in a step's json/body/headers.
80
+ */
81
+ export function secretFieldNames(spec) {
82
+ const names = new Set();
83
+ for (const s of [...(spec?.setup ?? []), ...(spec?.restore ?? [])]) {
84
+ if (s.auth?.from === "secretField")
85
+ names.add(s.auth.name);
86
+ for (const text of stringsIn([s.headers ?? {}, s.json ?? null, s.body ?? ""])) {
87
+ for (const m of text.matchAll(SECRET_FIELD_REF))
88
+ names.add(m[1]);
89
+ }
90
+ }
91
+ return [...names];
92
+ }
93
+ /** Every fixture identity (#243) a spec's steps authenticate as (`auth.identity`). */
94
+ export function identityNames(spec) {
95
+ const names = new Set();
96
+ for (const s of [...(spec?.setup ?? []), ...(spec?.restore ?? [])]) {
97
+ if (s.auth !== undefined && s.auth.from !== "secretField" && s.auth.identity !== undefined)
98
+ names.add(s.auth.identity);
99
+ }
100
+ return [...names];
101
+ }
102
+ /** A header value that is only references (and at most a scheme word) holds no literal credential. */
103
+ function onlyReferences(value) {
104
+ if (value.search(STEP_REF) === -1)
105
+ return false;
106
+ return /^([A-Za-z-]+)?$/.test(value.replace(STEP_REF, "").trim());
107
+ }
108
+ function stringsIn(v, out = []) {
109
+ if (typeof v === "string")
110
+ out.push(v);
111
+ else if (Array.isArray(v))
112
+ for (const x of v)
113
+ stringsIn(x, out);
114
+ else if (isRecord(v))
115
+ for (const x of Object.values(v))
116
+ stringsIn(x, out);
117
+ return out;
118
+ }
119
+ function findCommandKey(v, path) {
120
+ if (Array.isArray(v)) {
121
+ for (const [i, x] of v.entries()) {
122
+ const hit = findCommandKey(x, `${path}[${i}]`);
123
+ if (hit !== null)
124
+ return hit;
125
+ }
126
+ }
127
+ else if (isRecord(v)) {
128
+ for (const [k, x] of Object.entries(v)) {
129
+ if (COMMAND_KEYS.has(k.toLowerCase()))
130
+ return `${path}.${k}`;
131
+ // `json` is an opaque request body: its keys are the app's, not ours.
132
+ if (k === "json")
133
+ continue;
134
+ const hit = findCommandKey(x, `${path}.${k}`);
135
+ if (hit !== null)
136
+ return hit;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ function resolveStepUrl(url, baseUrl) {
142
+ try {
143
+ return new URL(url, baseUrl);
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ /** Throws unless `url` (with references substituted) is an http(s) URL on an allowlisted origin. */
150
+ function assertStepUrl(url, bounds, where) {
151
+ const u = resolveStepUrl(url, bounds.baseUrl);
152
+ if (u === null || (u.protocol !== "http:" && u.protocol !== "https:")) {
153
+ throw new FixtureSpecError(`${where}.url must be an http(s) URL (got ${JSON.stringify(redactUrl(url))})`);
154
+ }
155
+ if (!isAuthorizedExploreTarget(u.href, bounds.allowlist)) {
156
+ throw new FixtureSpecError(`${where}.url origin ${u.origin} is not an --allow origin (${[...bounds.allowlist].join(", ")})`);
157
+ }
158
+ }
159
+ function parseAuth(v, where) {
160
+ if (!isRecord(v) || typeof v.from !== "string")
161
+ throw new FixtureSpecError(`${where} must be {from: "localStorage"|"cookies"|"secretField", ...}`);
162
+ const opt = (k) => {
163
+ const x = v[k];
164
+ if (x === undefined)
165
+ return undefined;
166
+ if (typeof x !== "string")
167
+ throw new FixtureSpecError(`${where}.${k} must be a string`);
168
+ return x;
169
+ };
170
+ const allowed = (keys) => {
171
+ for (const k of Object.keys(v))
172
+ if (!keys.includes(k))
173
+ throw new FixtureSpecError(`${where}.${k} is not a known auth key`);
174
+ };
175
+ const scheme = opt("scheme");
176
+ const header = opt("header");
177
+ const extra = { ...(scheme === undefined ? {} : { scheme }), ...(header === undefined ? {} : { header: header.toLowerCase() }) };
178
+ // #243: authenticate this step as a NAMED identity (its own storageState), not the mission's session.
179
+ const identity = opt("identity");
180
+ if (identity !== undefined && !IDENTITY_NAME.test(identity)) {
181
+ throw new FixtureSpecError(`${where}.identity must be an identity name (1-64 of [A-Za-z0-9_.-], starting alphanumeric)`);
182
+ }
183
+ const as = identity === undefined ? {} : { identity };
184
+ if (v.from === "cookies") {
185
+ allowed(["from", "identity"]);
186
+ return { from: "cookies", ...as };
187
+ }
188
+ if (v.from === "localStorage") {
189
+ allowed(["from", "key", "scheme", "header", "identity"]);
190
+ const key = opt("key");
191
+ if (key === undefined || key === "")
192
+ throw new FixtureSpecError(`${where}.key (the localStorage item) is required`);
193
+ return { from: "localStorage", key, ...extra, ...as };
194
+ }
195
+ if (v.from === "secretField") {
196
+ allowed(["from", "name", "scheme", "header"]);
197
+ const name = opt("name");
198
+ if (name === undefined || name === "")
199
+ throw new FixtureSpecError(`${where}.name (the --secret-field env variable) is required`);
200
+ return { from: "secretField", name, ...extra };
201
+ }
202
+ throw new FixtureSpecError(`${where}.from must be localStorage, cookies or secretField`);
203
+ }
204
+ function parseStep(v, where, bounds, known, phase) {
205
+ if (!isRecord(v))
206
+ throw new FixtureSpecError(`${where} must be an object`);
207
+ const cmd = findCommandKey(v, where);
208
+ if (cmd !== null) {
209
+ throw new FixtureSpecError(`${cmd}: a fixtures file cannot declare a command — shell hooks are the operator's own flags (--before/--after with --allow-shell-hooks)`);
210
+ }
211
+ for (const k of Object.keys(v))
212
+ if (!STEP_KEYS.has(k))
213
+ throw new FixtureSpecError(`${where}.${k} is not a known step key`);
214
+ const method = typeof v.method === "string" ? v.method.toUpperCase() : "";
215
+ if (!METHODS.has(method))
216
+ throw new FixtureSpecError(`${where}.method must be one of ${[...METHODS].join(", ")}`);
217
+ if (typeof v.url !== "string" || v.url === "")
218
+ throw new FixtureSpecError(`${where}.url is required`);
219
+ // A URL is logged and persisted: a secret never goes there (#166).
220
+ if (v.url.search(SECRET_FIELD_REF) !== -1) {
221
+ throw new FixtureSpecError(`${where}.url: \${secretField.*} is allowed only in json, body or headers (a URL is logged)`);
222
+ }
223
+ // A reference may fill a path or query value, never choose the origin: check it with placeholders.
224
+ assertStepUrl(v.url.replace(SETUP_REF, "0"), bounds, where);
225
+ const u = resolveStepUrl(v.url.replace(SETUP_REF, "0"), bounds.baseUrl);
226
+ const probe = resolveStepUrl(v.url.replace(SETUP_REF, "x.evil.test"), bounds.baseUrl);
227
+ if (u === null || probe === null || probe.origin !== u.origin)
228
+ throw new FixtureSpecError(`${where}.url: a \${setup.*} reference cannot choose the origin`);
229
+ let headers;
230
+ if (v.headers !== undefined) {
231
+ if (!isRecord(v.headers))
232
+ throw new FixtureSpecError(`${where}.headers must be an object of strings`);
233
+ headers = {};
234
+ for (const [k, x] of Object.entries(v.headers)) {
235
+ if (typeof x !== "string")
236
+ throw new FixtureSpecError(`${where}.headers.${k} must be a string`);
237
+ // A credential header may only be references (`Bearer ${setup.token}`, `${secretField.API_KEY}`, #166).
238
+ if (CREDENTIAL_HEADERS.has(k.toLowerCase()) && !onlyReferences(x)) {
239
+ throw new FixtureSpecError(`${where}.headers.${k}: no literal credentials in a fixtures file — use "auth" (storage state or --secret-field), \${secretField.<VAR>} or a secret \${setup.<name>}`);
240
+ }
241
+ headers[k] = x;
242
+ }
243
+ }
244
+ if (v.json !== undefined && v.body !== undefined)
245
+ throw new FixtureSpecError(`${where}: give json or body, not both`);
246
+ if (v.body !== undefined && typeof v.body !== "string")
247
+ throw new FixtureSpecError(`${where}.body must be a string`);
248
+ let expectStatus;
249
+ if (v.expectStatus !== undefined) {
250
+ if (!Array.isArray(v.expectStatus) || v.expectStatus.length === 0 || !v.expectStatus.every((s) => Number.isInteger(s) && s >= 100 && s <= 599)) {
251
+ throw new FixtureSpecError(`${where}.expectStatus must be a non-empty array of HTTP status codes`);
252
+ }
253
+ expectStatus = v.expectStatus;
254
+ }
255
+ if (v.timeoutMs !== undefined && !(Number.isInteger(v.timeoutMs) && v.timeoutMs > 0)) {
256
+ throw new FixtureSpecError(`${where}.timeoutMs must be a positive integer`);
257
+ }
258
+ let outputs;
259
+ if (v.outputs !== undefined) {
260
+ if (phase === "restore")
261
+ throw new FixtureSpecError(`${where}.outputs: only setup steps bind outputs`);
262
+ if (!isRecord(v.outputs))
263
+ throw new FixtureSpecError(`${where}.outputs must be an object of name → "$.path" | "header:<name>"`);
264
+ outputs = {};
265
+ for (const [name, path] of Object.entries(v.outputs)) {
266
+ if (!OUTPUT_NAME.test(name))
267
+ throw new FixtureSpecError(`${where}.outputs.${name}: output names are identifiers`);
268
+ if (typeof path !== "string" || !(path.startsWith("$") || path.startsWith("header:"))) {
269
+ throw new FixtureSpecError(`${where}.outputs.${name} must be "$.path" or "header:<name>"`);
270
+ }
271
+ if (path.startsWith("$"))
272
+ parseJsonPath(path, `${where}.outputs.${name}`);
273
+ outputs[name] = path;
274
+ }
275
+ }
276
+ let secretOutputs;
277
+ if (v.secretOutputs !== undefined) {
278
+ if (!Array.isArray(v.secretOutputs) || !v.secretOutputs.every((s) => typeof s === "string" && outputs?.[s] !== undefined)) {
279
+ throw new FixtureSpecError(`${where}.secretOutputs must name this step's own outputs`);
280
+ }
281
+ secretOutputs = v.secretOutputs;
282
+ }
283
+ const step = {
284
+ ...(typeof v.name === "string" ? { name: v.name } : {}),
285
+ method,
286
+ url: v.url,
287
+ ...(headers === undefined ? {} : { headers }),
288
+ ...(v.json === undefined ? {} : { json: v.json }),
289
+ ...(v.body === undefined ? {} : { body: v.body }),
290
+ ...(v.auth === undefined ? {} : { auth: parseAuth(v.auth, `${where}.auth`) }),
291
+ ...(expectStatus === undefined ? {} : { expectStatus }),
292
+ ...(outputs === undefined ? {} : { outputs }),
293
+ ...(secretOutputs === undefined ? {} : { secretOutputs }),
294
+ ...(v.timeoutMs === undefined ? {} : { timeoutMs: v.timeoutMs }),
295
+ };
296
+ if (known !== null) {
297
+ for (const s of stringsIn([step.url, step.headers ?? {}, step.json ?? null, step.body ?? ""])) {
298
+ for (const name of referencedNames(s)) {
299
+ if (!known.has(name))
300
+ throw new FixtureSpecError(`${where} references \${setup.${name}}, which no earlier setup step outputs`);
301
+ }
302
+ }
303
+ }
304
+ return step;
305
+ }
306
+ /**
307
+ * Validates a fixtures spec (fail closed, before any browser or request). `openRefs` allows
308
+ * references a `--before` hook may bind at run time (unknown statically; still checked when used).
309
+ */
310
+ export function parseFixtureSpec(raw, bounds, opts = {}) {
311
+ if (!isRecord(raw))
312
+ throw new FixtureSpecError("a fixtures spec must be a JSON object {setup, restore}");
313
+ const cmd = findCommandKey(Object.fromEntries(Object.entries(raw).filter(([k]) => k !== "setup" && k !== "restore" && k !== "teardown")), "fixtures");
314
+ if (cmd !== null) {
315
+ throw new FixtureSpecError(`${cmd}: a fixtures file cannot declare a command — shell hooks are the operator's own flags (--before/--after with --allow-shell-hooks)`);
316
+ }
317
+ for (const k of Object.keys(raw)) {
318
+ if (!["name", "setup", "restore", "teardown", "version"].includes(k))
319
+ throw new FixtureSpecError(`fixtures.${k} is not a known key`);
320
+ }
321
+ if (raw.version !== undefined && raw.version !== 1)
322
+ throw new FixtureSpecError("fixtures.version must be 1");
323
+ if (raw.restore !== undefined && raw.teardown !== undefined)
324
+ throw new FixtureSpecError("give restore or teardown (an alias), not both");
325
+ const list = (v, where) => {
326
+ if (v === undefined)
327
+ return [];
328
+ if (!Array.isArray(v))
329
+ throw new FixtureSpecError(`${where} must be an array of steps`);
330
+ return v;
331
+ };
332
+ const known = opts.openRefs === true ? null : new Set();
333
+ const outputsSeen = new Set();
334
+ const setup = list(raw.setup, "fixtures.setup").map((s, i) => {
335
+ const step = parseStep(s, `fixtures.setup[${i}]`, bounds, known, "setup");
336
+ for (const name of Object.keys(step.outputs ?? {})) {
337
+ if (outputsSeen.has(name))
338
+ throw new FixtureSpecError(`fixtures.setup[${i}].outputs.${name} is bound twice`);
339
+ outputsSeen.add(name);
340
+ known?.add(name);
341
+ }
342
+ return step;
343
+ });
344
+ const restoreKey = raw.teardown !== undefined ? "teardown" : "restore";
345
+ const restore = list(raw[restoreKey], `fixtures.${restoreKey}`).map((s, i) => parseStep(s, `fixtures.${restoreKey}[${i}]`, bounds, known, "restore"));
346
+ if (setup.length === 0 && restore.length === 0)
347
+ throw new FixtureSpecError("a fixtures spec needs at least one setup or restore step");
348
+ // A credential header built from a setup output needs that output to be SECRET: a public output
349
+ // is persisted with the result, and a credential never is.
350
+ if (known !== null) {
351
+ const secret = new Set(setup.flatMap((s) => s.secretOutputs ?? []));
352
+ for (const [phase, steps] of [["setup", setup], [restoreKey, restore]]) {
353
+ for (const [i, s] of steps.entries()) {
354
+ for (const [k, x] of Object.entries(s.headers ?? {})) {
355
+ if (!CREDENTIAL_HEADERS.has(k.toLowerCase()))
356
+ continue;
357
+ for (const name of referencedNames(x)) {
358
+ if (!secret.has(name))
359
+ throw new FixtureSpecError(`fixtures.${phase}[${i}].headers.${k}: \${setup.${name}} carries a credential — list it in secretOutputs`);
360
+ }
361
+ }
362
+ }
363
+ }
364
+ }
365
+ return { ...(typeof raw.name === "string" ? { name: raw.name } : {}), setup, restore };
366
+ }
367
+ /** Reads and validates a fixtures JSON file. */
368
+ export function loadFixtureFile(path, bounds, opts = {}) {
369
+ let raw;
370
+ try {
371
+ raw = JSON.parse(readFileSync(path, "utf8"));
372
+ }
373
+ catch (e) {
374
+ throw new FixtureSpecError(`cannot read fixtures file ${path}: ${e instanceof SyntaxError ? "not valid JSON" : e instanceof Error ? e.message : String(e)}`);
375
+ }
376
+ return parseFixtureSpec(raw, bounds, opts);
377
+ }
378
+ export function parseJsonPath(path, where = "path") {
379
+ if (!path.startsWith("$"))
380
+ throw new FixtureSpecError(`${where}: a JSON path starts with $`);
381
+ const segs = [];
382
+ const re = /\.([A-Za-z_$][A-Za-z0-9_$-]*)|\[(\d+)\]|\[\s*"([^"]*)"\s*\]|\[\s*'([^']*)'\s*\]/y;
383
+ let i = 1;
384
+ while (i < path.length) {
385
+ re.lastIndex = i;
386
+ const m = re.exec(path);
387
+ if (m === null)
388
+ throw new FixtureSpecError(`${where}: cannot parse ${JSON.stringify(path)} at offset ${i}`);
389
+ segs.push(m[1] ?? (m[2] !== undefined ? Number(m[2]) : (m[3] ?? m[4] ?? "")));
390
+ i = re.lastIndex;
391
+ }
392
+ return segs;
393
+ }
394
+ /** The scalar at `path` in `doc`, as a string; `undefined` when absent or not a scalar. */
395
+ export function selectJsonPath(doc, path) {
396
+ let cur = doc;
397
+ for (const seg of parseJsonPath(path)) {
398
+ if (typeof seg === "number")
399
+ cur = Array.isArray(cur) ? cur[seg] : undefined;
400
+ else
401
+ cur = isRecord(cur) ? cur[seg] : undefined;
402
+ }
403
+ if (typeof cur === "string")
404
+ return cur;
405
+ if (typeof cur === "number" || typeof cur === "boolean")
406
+ return String(cur);
407
+ return undefined;
408
+ }
409
+ export class UnboundSetupRefError extends Error {
410
+ code = "E_FIXTURE_REF";
411
+ constructor(message) {
412
+ super(message);
413
+ this.name = "UnboundSetupRefError";
414
+ }
415
+ }
416
+ /**
417
+ * Substitutes every `${setup.x}` in `text`. An unknown name throws (never a hollow run); a SECRET
418
+ * output throws unless `allowSecret` — goal/URL/success text is model- or artifact-visible.
419
+ */
420
+ export function substituteSetupRefs(text, b, opts = {}) {
421
+ return text.replace(SETUP_REF, (_whole, name) => {
422
+ const v = b.values[name];
423
+ if (v === undefined)
424
+ throw new UnboundSetupRefError(`${opts.where ?? "text"} references \${setup.${name}}, which the fixture setup did not output`);
425
+ if (b.secretNames.has(name) && opts.allowSecret !== true) {
426
+ throw new UnboundSetupRefError(`${opts.where ?? "text"} references the secret output \${setup.${name}}; secret outputs stay inside the fixture's own requests`);
427
+ }
428
+ return v;
429
+ });
430
+ }
431
+ /**
432
+ * A step's `json`/`body`/`headers` text with every `${setup.x}` and `${secretField.VAR}` filled in,
433
+ * in one pass (#166). The result goes on the wire ONLY — never into a log line or an artifact.
434
+ */
435
+ function substituteStepText(text, b, secretFields, where) {
436
+ return text.replace(STEP_REF, (_whole, kind, name) => {
437
+ if (kind === "setup") {
438
+ const v = b.values[name];
439
+ if (v === undefined)
440
+ throw new UnboundSetupRefError(`${where} references \${setup.${name}}, which the fixture setup did not output`);
441
+ return v;
442
+ }
443
+ const v = secretFields[name];
444
+ if (v === undefined || v === "")
445
+ throw new UnboundSetupRefError(`${where} references \${secretField.${name}}, but the run has no --secret-field bound to env:${name}`);
446
+ return v;
447
+ });
448
+ }
449
+ function substituteDeep(v, sub) {
450
+ if (typeof v === "string")
451
+ return sub(v);
452
+ if (Array.isArray(v))
453
+ return v.map((x) => substituteDeep(x, sub));
454
+ if (isRecord(v))
455
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, substituteDeep(x, sub)]));
456
+ return v;
457
+ }
458
+ function sha256(text) {
459
+ return createHash("sha256").update(text).digest("hex");
460
+ }
461
+ function canonical(v) {
462
+ if (Array.isArray(v))
463
+ return `[${v.map(canonical).join(",")}]`;
464
+ if (isRecord(v)) {
465
+ return `{${Object.keys(v)
466
+ .sort()
467
+ .filter((k) => v[k] !== undefined)
468
+ .map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`)
469
+ .join(",")}}`;
470
+ }
471
+ return JSON.stringify(v);
472
+ }
473
+ export function hookHash(cmd) {
474
+ return sha256(`hook:${cmd}`).slice(0, 16);
475
+ }
476
+ function clip(s, n = MAX_LOGGED_OUTPUT) {
477
+ return s.length > n ? `${s.slice(0, n)}…` : s;
478
+ }
479
+ function firstLine(e) {
480
+ const m = e instanceof Error ? e.message : String(e);
481
+ return m.split("\n")[0] ?? m;
482
+ }
483
+ function runHook(cmd, timeoutMs, stdin, env) {
484
+ return new Promise((resolve) => {
485
+ const child = spawn(cmd, { shell: true, detached: true, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...env } });
486
+ let stdout = "";
487
+ let stderr = "";
488
+ let timedOut = false;
489
+ const killGroup = () => {
490
+ try {
491
+ if (child.pid !== undefined)
492
+ process.kill(-child.pid, "SIGKILL");
493
+ }
494
+ catch {
495
+ child.kill("SIGKILL");
496
+ }
497
+ };
498
+ const timer = clock.setTimeout(() => {
499
+ timedOut = true;
500
+ killGroup();
501
+ }, timeoutMs);
502
+ child.stdout.on("data", (d) => {
503
+ if (stdout.length < MAX_HOOK_STDOUT)
504
+ stdout += d.toString("utf8");
505
+ });
506
+ child.stderr.on("data", (d) => {
507
+ if (stderr.length < MAX_HOOK_STDOUT)
508
+ stderr += d.toString("utf8");
509
+ });
510
+ child.on("error", (e) => {
511
+ clock.clearTimeout(timer);
512
+ resolve({ exitCode: null, timedOut, stdout, stderr: `${stderr}${e.message}` });
513
+ });
514
+ child.on("close", (code) => {
515
+ clock.clearTimeout(timer);
516
+ resolve({ exitCode: code, timedOut, stdout, stderr });
517
+ });
518
+ child.stdin.on("error", () => undefined);
519
+ child.stdin.end(stdin);
520
+ });
521
+ }
522
+ /**
523
+ * One mission's fixture lifecycle. `setup()` before the mission, `restore()` after it (every exit
524
+ * path), and `reset()` (restore the active state, then set up again) before each replay.
525
+ */
526
+ export class MissionFixtures {
527
+ #opts;
528
+ #specHash;
529
+ #values = {};
530
+ #secretNames = new Set();
531
+ #active = false;
532
+ #cycles = 0;
533
+ #log = [];
534
+ constructor(opts) {
535
+ if (opts.hooks !== undefined && (opts.hooks.before !== undefined || opts.hooks.after !== undefined) && opts.allowShellHooks !== true) {
536
+ throw new FixtureSpecError("--before/--after run operator shell commands: pass --allow-shell-hooks to opt in");
537
+ }
538
+ // #166: every `${secretField.VAR}` must name a binding this run has — refused here, before any
539
+ // browser or request, like an unknown `${setup.x}`.
540
+ const bound = opts.auth.secretFields ?? {};
541
+ for (const [phase, steps] of [["setup", opts.spec?.setup ?? []], ["restore", opts.spec?.restore ?? []]]) {
542
+ for (const [i, s] of steps.entries()) {
543
+ for (const text of stringsIn([s.headers ?? {}, s.json ?? null, s.body ?? ""])) {
544
+ for (const m of text.matchAll(SECRET_FIELD_REF)) {
545
+ const v = bound[m[1]];
546
+ if (v === undefined || v === "") {
547
+ throw new FixtureSpecError(`fixtures.${phase}[${i}] references \${secretField.${m[1]}}, but the run has no --secret-field bound to env:${m[1]}`);
548
+ }
549
+ }
550
+ }
551
+ }
552
+ }
553
+ // #243: every `auth.identity` must be bound to a storageState file — refused here, before any
554
+ // browser or request (never a silent fall back to the mission's own session).
555
+ for (const name of identityNames(opts.spec)) {
556
+ if (opts.auth.identities?.[name] === undefined) {
557
+ throw new FixtureSpecError(`the fixture authenticates as identity ${name}, which is not bound: pass --fixture-identity ${name}=<storageState> or declare personas.${name}.storageState for this origin in targets.json`);
558
+ }
559
+ }
560
+ this.#opts = opts;
561
+ this.#specHash = sha256(canonical({ spec: opts.spec ?? null, hooks: this.hookHashes() ?? null })).slice(0, 16);
562
+ }
563
+ hookHashes() {
564
+ const h = this.#opts.hooks;
565
+ if (h?.before === undefined && h?.after === undefined)
566
+ return undefined;
567
+ return { ...(h.before === undefined ? {} : { before: hookHash(h.before) }), ...(h.after === undefined ? {} : { after: hookHash(h.after) }) };
568
+ }
569
+ get specHash() {
570
+ return this.#specHash;
571
+ }
572
+ /** The output names the spec declares; `null` when a `--before` hook may bind more at run time. */
573
+ declaredOutputs() {
574
+ if (this.#opts.hooks?.before !== undefined)
575
+ return null;
576
+ return new Set((this.#opts.spec?.setup ?? []).flatMap((s) => Object.keys(s.outputs ?? {})));
577
+ }
578
+ /** The output names the spec declares secret. */
579
+ declaredSecretOutputs() {
580
+ return new Set((this.#opts.spec?.setup ?? []).flatMap((s) => s.secretOutputs ?? []));
581
+ }
582
+ bindings() {
583
+ return { values: { ...this.#values }, secretNames: new Set(this.#secretNames) };
584
+ }
585
+ /** Non-secret outputs (what a replay's recorded values are rebound to). */
586
+ publicOutputs() {
587
+ return Object.fromEntries(Object.entries(this.#values).filter(([k]) => !this.#secretNames.has(k)));
588
+ }
589
+ /** Secret output values: the caller adds them to the run's redaction set. */
590
+ secrets() {
591
+ return [...this.#secretNames].map((n) => this.#values[n]).filter((v) => v !== undefined && v !== "");
592
+ }
593
+ #redact(text) {
594
+ const bound = Object.values(this.#opts.auth.secretFields ?? {}).filter((v) => v !== "");
595
+ return redactUrl(redactText(text, [...(this.#opts.secrets ?? []), ...bound, ...this.secrets()]));
596
+ }
597
+ identity() {
598
+ return `fx-${sha256(canonical({ spec: this.#specHash, outputs: this.publicOutputs(), secret: [...this.#secretNames].sort() })).slice(0, 16)}`;
599
+ }
600
+ record() {
601
+ return {
602
+ identity: this.identity(),
603
+ specHash: this.#specHash,
604
+ outputs: this.publicOutputs(),
605
+ secretOutputs: [...this.#secretNames].sort(),
606
+ cycles: this.#cycles,
607
+ log: [...this.#log],
608
+ };
609
+ }
610
+ /** #243: the identities the spec's steps use → their storageState paths (paths only). */
611
+ identities() {
612
+ const names = identityNames(this.#opts.spec);
613
+ if (names.length === 0)
614
+ return undefined;
615
+ return Object.fromEntries(names.sort().map((n) => [n, this.#opts.auth.identities?.[n]]));
616
+ }
617
+ persisted() {
618
+ const hooks = this.hookHashes();
619
+ const identities = this.identities();
620
+ return {
621
+ identity: this.identity(),
622
+ specHash: this.#specHash,
623
+ ...(this.#opts.spec === undefined ? {} : { spec: this.#opts.spec }),
624
+ ...(hooks === undefined ? {} : { hooks }),
625
+ outputs: this.publicOutputs(),
626
+ secretOutputs: [...this.#secretNames].sort(),
627
+ ...(identities === undefined ? {} : { identities }),
628
+ };
629
+ }
630
+ /** Sets up the fixture. Throws `FixtureSetupError`; the caller still calls `restore()`. */
631
+ async setup() {
632
+ this.#values = {};
633
+ this.#secretNames = new Set();
634
+ this.#active = true;
635
+ this.#cycles += 1;
636
+ const before = this.#opts.hooks?.before;
637
+ if (before !== undefined)
638
+ await this.#runSetupHook(before);
639
+ for (const [i, step] of (this.#opts.spec?.setup ?? []).entries()) {
640
+ const failure = await this.#runHttp(step, "setup", i);
641
+ if (failure !== null)
642
+ throw new FixtureSetupError(failure);
643
+ }
644
+ }
645
+ /** Restores the state the last `setup()` created. Never throws: every step is logged. */
646
+ async restore() {
647
+ if (!this.#active)
648
+ return;
649
+ this.#active = false;
650
+ for (const [i, step] of (this.#opts.spec?.restore ?? []).entries())
651
+ await this.#runHttp(step, "restore", i);
652
+ const after = this.#opts.hooks?.after;
653
+ if (after !== undefined) {
654
+ const started = clock.now();
655
+ const r = await runHook(after, this.#opts.hooks?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS, JSON.stringify({ vars: this.publicOutputs() }), {
656
+ JEVITATE_FIXTURE_PHASE: "restore",
657
+ });
658
+ this.#log.push({
659
+ phase: "restore",
660
+ kind: "shell",
661
+ name: "--after",
662
+ ok: !r.timedOut && r.exitCode === 0,
663
+ durationMs: clock.now() - started,
664
+ exitCode: r.exitCode,
665
+ ...(r.timedOut ? { detail: "timed out" } : {}),
666
+ ...(r.stderr === "" ? {} : { stderr: clip(this.#redact(r.stderr)) }),
667
+ });
668
+ }
669
+ }
670
+ /** Before a replay: restore whatever is active, then set up afresh. Throws `FixtureSetupError`. */
671
+ async reset() {
672
+ await this.restore();
673
+ await this.setup();
674
+ }
675
+ async #runSetupHook(cmd) {
676
+ const started = clock.now();
677
+ const r = await runHook(cmd, this.#opts.hooks?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS, "", { JEVITATE_FIXTURE_PHASE: "setup" });
678
+ let detail;
679
+ if (r.timedOut)
680
+ detail = "the --before hook timed out";
681
+ else if (r.exitCode !== 0)
682
+ detail = `the --before hook exited ${r.exitCode ?? "abnormally"}`;
683
+ else
684
+ detail = this.#bindHookStdout(r.stdout);
685
+ this.#log.push({
686
+ phase: "setup",
687
+ kind: "shell",
688
+ name: "--before",
689
+ ok: detail === undefined,
690
+ durationMs: clock.now() - started,
691
+ exitCode: r.exitCode,
692
+ ...(detail === undefined ? {} : { detail }),
693
+ ...(r.stderr === "" ? {} : { stderr: clip(this.#redact(r.stderr)) }),
694
+ });
695
+ if (detail !== undefined)
696
+ throw new FixtureSetupError(detail);
697
+ }
698
+ /** The `--before` stdout contract: empty, or one JSON object `{vars: {name: scalar}, secret?: [name]}`. */
699
+ #bindHookStdout(stdout) {
700
+ const text = stdout.trim();
701
+ if (text === "")
702
+ return undefined;
703
+ let parsed;
704
+ try {
705
+ parsed = JSON.parse(text);
706
+ }
707
+ catch {
708
+ return "the --before hook printed something other than one JSON object {vars, secret}";
709
+ }
710
+ if (!isRecord(parsed) || (parsed.vars !== undefined && !isRecord(parsed.vars)))
711
+ return "the --before hook's stdout must be {vars: {...}, secret?: [...]}";
712
+ const secret = Array.isArray(parsed.secret) ? parsed.secret.filter((s) => typeof s === "string") : [];
713
+ for (const [name, v] of Object.entries((parsed.vars ?? {}))) {
714
+ if (!OUTPUT_NAME.test(name))
715
+ return `the --before hook var ${JSON.stringify(name)} is not an identifier`;
716
+ if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean")
717
+ return `the --before hook var ${name} is not a scalar`;
718
+ this.#values[name] = String(v);
719
+ if (secret.includes(name))
720
+ this.#secretNames.add(name);
721
+ }
722
+ return undefined;
723
+ }
724
+ /** Runs one HTTP step; returns the failure detail (redacted), or null. */
725
+ async #runHttp(step, phase, i) {
726
+ const started = clock.now();
727
+ const name = step.name ?? `${phase}[${i}]`;
728
+ const log = (entry) => {
729
+ this.#log.push({ phase, kind: "http", name, method: step.method, durationMs: clock.now() - started, ...entry });
730
+ };
731
+ let url;
732
+ let body;
733
+ let headers;
734
+ try {
735
+ const b = this.bindings();
736
+ url = new URL(substituteSetupRefs(step.url, b, { allowSecret: true, where: name }), this.#opts.baseUrl).href;
737
+ // Re-check the substituted URL: a bound value never widens the allowlist.
738
+ assertStepUrl(url, this.#opts, name);
739
+ const sub = (text) => substituteStepText(text, b, this.#opts.auth.secretFields ?? {}, name);
740
+ for (const [k, x] of Object.entries(step.headers ?? {})) {
741
+ // A `--before` hook may bind names at run time: a credential header still takes secret outputs only.
742
+ const pub = CREDENTIAL_HEADERS.has(k.toLowerCase()) ? referencedNames(x).find((n) => !b.secretNames.has(n)) : undefined;
743
+ if (pub !== undefined)
744
+ throw new UnboundSetupRefError(`${name}: headers.${k} uses \${setup.${pub}}, which is not a secret output`);
745
+ }
746
+ headers = { ...substituteDeep(step.headers ?? {}, sub) };
747
+ if (step.json !== undefined) {
748
+ body = JSON.stringify(substituteDeep(step.json, sub));
749
+ headers["content-type"] ??= "application/json";
750
+ }
751
+ else if (step.body !== undefined) {
752
+ body = sub(step.body);
753
+ }
754
+ if (step.auth !== undefined)
755
+ Object.assign(headers, authHeaders(step.auth, url, this.#opts.auth));
756
+ }
757
+ catch (e) {
758
+ const detail = `${name}: ${this.#redact(firstLine(e))}`;
759
+ log({ ok: false, url: this.#redact(step.url), detail });
760
+ return detail;
761
+ }
762
+ const shownUrl = this.#redact(url);
763
+ const controller = new AbortController();
764
+ const timer = clock.setTimeout(() => controller.abort(), step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS);
765
+ try {
766
+ const res = await (this.#opts.fetchImpl ?? fetch)(url, {
767
+ method: step.method,
768
+ headers,
769
+ ...(body === undefined ? {} : { body }),
770
+ // Never follow a redirect: it could leave the allowlist.
771
+ redirect: "manual",
772
+ signal: controller.signal,
773
+ });
774
+ const accepted = step.expectStatus !== undefined ? step.expectStatus.includes(res.status) : res.status >= 200 && res.status < 300;
775
+ if (!accepted) {
776
+ const detail = `${name}: ${step.method} ${shownUrl} answered ${res.status}`;
777
+ log({ ok: false, url: shownUrl, status: res.status, detail });
778
+ return detail;
779
+ }
780
+ if (step.outputs !== undefined) {
781
+ const text = await res.text();
782
+ let doc;
783
+ const needsJson = Object.values(step.outputs).some((p) => p.startsWith("$"));
784
+ if (needsJson) {
785
+ try {
786
+ doc = JSON.parse(text);
787
+ }
788
+ catch {
789
+ const detail = `${name}: the response is not JSON, so its outputs cannot be read`;
790
+ log({ ok: false, url: shownUrl, status: res.status, detail });
791
+ return detail;
792
+ }
793
+ }
794
+ for (const [out, path] of Object.entries(step.outputs)) {
795
+ const v = path.startsWith("header:") ? (res.headers.get(path.slice("header:".length)) ?? undefined) : selectJsonPath(doc, path);
796
+ if (v === undefined || v === "") {
797
+ const detail = `${name}: output ${out} (${path}) is missing from the response`;
798
+ log({ ok: false, url: shownUrl, status: res.status, detail });
799
+ return detail;
800
+ }
801
+ this.#values[out] = v;
802
+ if (step.secretOutputs?.includes(out) === true)
803
+ this.#secretNames.add(out);
804
+ }
805
+ }
806
+ else {
807
+ await res.body?.cancel().catch(() => undefined);
808
+ }
809
+ log({ ok: true, url: this.#redact(url), status: res.status });
810
+ return null;
811
+ }
812
+ catch (e) {
813
+ const detail = `${name}: ${step.method} ${shownUrl} failed: ${controller.signal.aborted ? "timed out" : this.#redact(firstLine(e))}`;
814
+ log({ ok: false, url: shownUrl, detail });
815
+ return detail;
816
+ }
817
+ finally {
818
+ clock.clearTimeout(timer);
819
+ }
820
+ }
821
+ }
822
+ /**
823
+ * A replay session re-uses the Recording of the ORIGINAL run, whose navigations carry the values
824
+ * that run's setup bound (e.g. `/items/<old id>`). After a fresh setup, the same navigation must go
825
+ * to the NEW value: this rewrites each recorded value to its current one in every `page.goto` of the
826
+ * replay's own page (non-secret outputs of 4+ characters only — shorter ones are too ambiguous to
827
+ * rewrite blindly).
828
+ */
829
+ export function rebindReplayNavigation(page, recorded, current) {
830
+ const pairs = Object.entries(recorded)
831
+ .map(([k, from]) => [from, current[k]])
832
+ .filter((p) => p[1] !== undefined && p[0] !== p[1] && p[0].length >= 4);
833
+ if (pairs.length === 0)
834
+ return;
835
+ const original = page.goto.bind(page);
836
+ page.goto = ((url, ...rest) => {
837
+ let next = url;
838
+ for (const [from, to] of pairs) {
839
+ next = next.split(from).join(to).split(encodeURIComponent(from)).join(encodeURIComponent(to));
840
+ }
841
+ return original(next, ...rest);
842
+ });
843
+ }
844
+ /** The fixture identity carried on a Recording (non-secret outputs only). */
845
+ export function recordingFixture(r) {
846
+ return { identity: r.identity, specHash: r.specHash, outputs: { ...r.outputs } };
847
+ }
848
+ /**
849
+ * Wraps a replay-session opener (hang reproduction, `verify-fix`, regression capture) so EVERY
850
+ * replay starts from the fixture's state: restore whatever is active, set up afresh (a failure
851
+ * throws, so the replay is "could not open a session" — inconclusive, never evidence), then rebind
852
+ * the Recording's values (`recorded`, from the run the Recording came from) to the new outputs.
853
+ */
854
+ export function fixtureReplayOpener(open, fx, recorded) {
855
+ return async () => {
856
+ await fx.reset();
857
+ const session = await open();
858
+ rebindReplayNavigation(session.page, recorded, fx.publicOutputs());
859
+ return session;
860
+ };
861
+ }
862
+ //# sourceMappingURL=mission-fixtures.js.map