gentle-pi 3.7.0 → 4.1.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 (590) hide show
  1. package/README.md +155 -10
  2. package/assets/agents/gentle-ai-explore.md +13 -6
  3. package/assets/agents/gentle-ai-verify.md +27 -6
  4. package/assets/agents/gentle-ai-worker.md +13 -15
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +75 -169
  9. package/assets/orchestrator-memory.md +12 -26
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-skills.md +1 -1
  12. package/assets/orchestrator-tracking.md +53 -0
  13. package/assets/orchestrator-verification.md +28 -0
  14. package/assets/orchestrator-writer.md +38 -0
  15. package/assets/orchestrator.md +30 -38
  16. package/assets/sounds/LICENSE.md +52 -0
  17. package/assets/sounds/attention.wav +0 -0
  18. package/assets/sounds/error.wav +0 -0
  19. package/assets/sounds/success.wav +0 -0
  20. package/assets/support/strict-tdd-verify.md +4 -266
  21. package/assets/support/strict-tdd.md +8 -360
  22. package/bin/engram-guard +9 -0
  23. package/bin/gentle-shell-install.mjs +302 -0
  24. package/bin/gentle-shell.mjs +956 -50
  25. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  27. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  28. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  29. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  30. package/docs/bundled-install.md +397 -0
  31. package/docs/delegated-verification.md +27 -4
  32. package/docs/gentle-agents-activity.md +537 -0
  33. package/docs/gentle-shell-uninstall.md +136 -0
  34. package/docs/gentle-shell.md +165 -51
  35. package/docs/install-wizard.md +2040 -0
  36. package/docs/native-authority-architecture.md +3 -3
  37. package/docs/permission-modes.md +82 -0
  38. package/docs/prompt-history.md +280 -0
  39. package/docs/readme-reference.md +364 -231
  40. package/docs/render-cache-performance.md +28 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/telemetry.md +1 -1
  44. package/docs/yolo-mode.md +86 -0
  45. package/extensions/child-context.ts +35 -0
  46. package/extensions/child-safety.ts +25 -0
  47. package/extensions/codegraph-tools.ts +18 -5
  48. package/extensions/gentle-agents.ts +1093 -326
  49. package/extensions/gentle-ai.ts +1636 -890
  50. package/extensions/gentle-herdr-activity.ts +81 -0
  51. package/extensions/gentle-notifications.ts +104 -0
  52. package/extensions/gentle-shell.ts +1619 -136
  53. package/extensions/gentle-stats.ts +101 -0
  54. package/extensions/gentle-todo.ts +126 -20
  55. package/extensions/history/atomic-write.ts +38 -0
  56. package/extensions/history/hide-prompts.ts +183 -0
  57. package/extensions/history/index.ts +1424 -0
  58. package/extensions/history/load-shared-history.ts +39 -0
  59. package/extensions/history/selector-helpers.ts +538 -0
  60. package/extensions/history/session-scan.ts +233 -0
  61. package/extensions/history/store.ts +1119 -0
  62. package/extensions/nan-provider.ts +6 -0
  63. package/extensions/pi-pretty.ts +39 -3
  64. package/extensions/quiet-tools.ts +254 -92
  65. package/extensions/resume-hint.ts +60 -0
  66. package/extensions/skill-registry.ts +191 -58
  67. package/extensions/startup-banner.ts +71 -33
  68. package/lib/agent-assets.ts +604 -0
  69. package/lib/agent-home.ts +10 -1
  70. package/lib/agent-model-resolution.ts +80 -0
  71. package/lib/agent-profile-pin.ts +12 -0
  72. package/lib/agent-profiles.ts +13 -3
  73. package/lib/agents-completion-delivery.ts +11 -3
  74. package/lib/agents-message-delivery.ts +181 -0
  75. package/lib/agents-messaging.ts +1 -1
  76. package/lib/agents-protocol.ts +99 -0
  77. package/lib/agents-runner.ts +180 -107
  78. package/lib/agents-view.ts +26 -3
  79. package/lib/agents-widget.ts +16 -9
  80. package/lib/append-system-prompt.ts +21 -0
  81. package/lib/auto-permission-classifier.ts +280 -0
  82. package/lib/auto-permission-runtime.ts +268 -0
  83. package/lib/background-jobs-tools.ts +202 -0
  84. package/lib/background-jobs.ts +253 -0
  85. package/lib/background-monitor.ts +154 -0
  86. package/lib/bounded-writer-admission.ts +220 -0
  87. package/lib/builtin-codemode-optout.ts +166 -0
  88. package/lib/card-content-policy.ts +85 -0
  89. package/lib/card-style-policy.ts +60 -0
  90. package/lib/child-context-files.ts +166 -0
  91. package/lib/child-package-injection.ts +62 -0
  92. package/lib/codemode-renderer.ts +195 -0
  93. package/lib/command-palette-catalog.ts +5 -10
  94. package/lib/command-palette.ts +25 -14
  95. package/lib/context-threshold.ts +26 -0
  96. package/lib/destructive-command-guard.ts +144 -0
  97. package/lib/gentle-ai-binary.ts +109 -4
  98. package/lib/gentle-ai-elapsed-store.ts +87 -0
  99. package/lib/gentle-ai-renderer.ts +196 -39
  100. package/lib/gentle-shell-bundled.ts +517 -0
  101. package/lib/gentle-shell-launcher.ts +89 -27
  102. package/lib/gentle-shell-resume-hint.ts +176 -0
  103. package/lib/gentle-shell-startup-flow.ts +54 -0
  104. package/lib/gentle-shell-startup-update.ts +92 -0
  105. package/lib/gentle-shell-uninstall.ts +840 -0
  106. package/lib/gentle-shell-update-panel.ts +231 -0
  107. package/lib/herdr-activity.ts +183 -0
  108. package/lib/history-capture-policy.ts +95 -0
  109. package/lib/inprocess-reviewer.ts +3 -0
  110. package/lib/jobs-sidebar-state.ts +20 -0
  111. package/lib/jobs-view.ts +161 -0
  112. package/lib/model-price-ratio.ts +83 -0
  113. package/lib/model-routing-authority.ts +5 -1
  114. package/lib/nan-provider.ts +249 -0
  115. package/lib/native-review-cli.ts +242 -180
  116. package/lib/notification-audio-native.ts +162 -0
  117. package/lib/notification-audio-windows.ts +399 -0
  118. package/lib/notification-audio.ts +332 -0
  119. package/lib/notification-customize.ts +241 -0
  120. package/lib/notification-events.ts +113 -0
  121. package/lib/notification-policy.ts +148 -0
  122. package/lib/notification-pulse-client.ts +367 -0
  123. package/lib/notification-pulse-protocol.ts +305 -0
  124. package/lib/notification-pulse-stream.ts +213 -0
  125. package/lib/notification-pulse-worker.ts +66 -0
  126. package/lib/notification-scheduler.ts +173 -0
  127. package/lib/notification-service.ts +202 -0
  128. package/lib/notification-ui.ts +87 -0
  129. package/lib/odd-phase-inference.ts +231 -0
  130. package/lib/odd-phase.ts +141 -0
  131. package/lib/orchestrator-alias.ts +20 -0
  132. package/lib/orchestrator-catalog.ts +79 -0
  133. package/lib/orchestrator-consultation.ts +61 -0
  134. package/lib/orchestrator-discovery.ts +49 -0
  135. package/lib/orchestrator-helper-consent.ts +122 -0
  136. package/lib/orchestrator-helper.ts +167 -0
  137. package/lib/orchestrator-message-card.ts +105 -0
  138. package/lib/orchestrator-presence.ts +115 -14
  139. package/lib/orchestrator-scope.ts +80 -0
  140. package/lib/orchestrator-state.ts +107 -0
  141. package/lib/orchestrator-tool-card.ts +280 -0
  142. package/lib/orchestrator-work-search.ts +218 -0
  143. package/lib/orchestrator-work.ts +79 -0
  144. package/lib/overlay-repaint.ts +26 -0
  145. package/lib/permission-mode-policy.ts +59 -0
  146. package/lib/pi-tui-keys.ts +53 -0
  147. package/lib/review-candidate-view-owner.ts +73 -21
  148. package/lib/review-candidate-view.ts +115 -27
  149. package/lib/review-compact-contract.ts +2 -2
  150. package/lib/review-host-relay.ts +7 -2
  151. package/lib/review-integration-v2.ts +100 -22
  152. package/lib/review-publication-gate.ts +1 -1
  153. package/lib/review-reminder-receipt.ts +48 -8
  154. package/lib/review-risk-assessment.ts +227 -56
  155. package/lib/review-sidebar-state.ts +224 -0
  156. package/lib/review-transaction.ts +72 -3
  157. package/lib/runtime-metrics-children.ts +1 -1
  158. package/lib/runtime-metrics-native.ts +3 -2
  159. package/lib/selection-engine.ts +527 -0
  160. package/lib/session-change-capture.ts +5 -4
  161. package/lib/session-changes.ts +27 -22
  162. package/lib/session-messaging-grants.ts +135 -0
  163. package/lib/session-profile-binding.ts +83 -0
  164. package/lib/session-profile-freeze.ts +266 -0
  165. package/lib/session-worktree-registry.ts +87 -9
  166. package/lib/shell-bar.ts +215 -30
  167. package/lib/shell-card.ts +287 -18
  168. package/lib/shell-changes-view.ts +42 -14
  169. package/lib/shell-changes.ts +86 -71
  170. package/lib/shell-prompt.ts +102 -6
  171. package/lib/shell-sidebar-layout.ts +83 -15
  172. package/lib/shell-sidebar.ts +15 -1
  173. package/lib/shell-todo.ts +84 -31
  174. package/lib/shell-usage-view.ts +9 -4
  175. package/lib/shell-usage.ts +70 -13
  176. package/lib/stats-collector.ts +381 -0
  177. package/lib/stats-view.ts +431 -0
  178. package/lib/status-timing-diagnostics.ts +201 -0
  179. package/lib/telemetry-trigger.ts +3 -1
  180. package/lib/theme-customization.ts +52 -0
  181. package/lib/vim-editor-adapter.ts +383 -0
  182. package/lib/vim-normal-engine.ts +154 -0
  183. package/lib/vim-operator-engine.ts +416 -0
  184. package/lib/vim-policy.ts +49 -0
  185. package/lib/vim-visual-engine.ts +107 -0
  186. package/lib/visual-customization-policy.ts +119 -0
  187. package/lib/visual-customize-view.ts +481 -0
  188. package/lib/visual-profiles.ts +228 -0
  189. package/lib/writer-surfaces.ts +162 -0
  190. package/lib/yolo-session-policy.ts +317 -0
  191. package/package.json +20 -8
  192. package/runtime/child-package-injection.mjs +63 -0
  193. package/runtime/gentle-ai-binary.mjs +109 -4
  194. package/runtime/gentle-shell-bundled.mjs +518 -0
  195. package/runtime/gentle-shell-launcher.mjs +87 -25
  196. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  197. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  198. package/runtime/gentle-shell-startup-update.mjs +93 -0
  199. package/runtime/gentle-shell-uninstall.mjs +841 -0
  200. package/runtime/gentle-shell-update-panel.mjs +232 -0
  201. package/runtime/native-review-cli.mjs +239 -177
  202. package/runtime/review-integration-v2.mjs +100 -22
  203. package/runtime/review-risk-assessment.mjs +225 -54
  204. package/runtime/status-timing-diagnostics.mjs +202 -0
  205. package/runtime/telemetry-trigger.mjs +3 -1
  206. package/scripts/bootstrap.cmd +255 -0
  207. package/scripts/bootstrap.sh +168 -0
  208. package/scripts/build-distribution.mjs +157 -0
  209. package/scripts/build-installer-bundles.mjs +136 -0
  210. package/scripts/build-runtime-modules.mjs +8 -0
  211. package/scripts/bundled-install.mjs +1308 -0
  212. package/scripts/bundled-wizard.mjs +399 -0
  213. package/scripts/check-types.mjs +4 -6
  214. package/scripts/gentle-ai-installer.mjs +83 -35
  215. package/scripts/install-gentle-ai.mjs +48 -1
  216. package/scripts/install-tui-mode-setting.mjs +10 -1
  217. package/scripts/install-wizard-preview.mjs +154 -0
  218. package/scripts/installer-downloads.mjs +648 -0
  219. package/scripts/installer-preflight.mjs +387 -0
  220. package/scripts/installer-probes.mjs +564 -0
  221. package/scripts/installer-runner.mjs +1102 -0
  222. package/scripts/installer-server.mjs +881 -0
  223. package/scripts/installer-windows-artifacts.json +15 -0
  224. package/scripts/installer-windows.mjs +752 -0
  225. package/scripts/main-channel.mjs +452 -0
  226. package/scripts/mirror-odd-routing.mjs +2 -2
  227. package/scripts/npm-pack-result.mjs +31 -0
  228. package/scripts/render-cache-performance-worker.mjs +205 -0
  229. package/scripts/render-cache-performance.mjs +115 -0
  230. package/scripts/run-test-suite.mjs +90 -0
  231. package/scripts/test-installer-acceptance.sh +161 -0
  232. package/scripts/test-packed-runner.mjs +54 -19
  233. package/scripts/types-baseline.json +2 -92
  234. package/scripts/verify-package-files.mjs +91 -38
  235. package/skills/branch-pr/SKILL.md +24 -52
  236. package/skills/chained-pr/SKILL.md +31 -15
  237. package/skills/chained-pr/references/chaining-details.md +31 -20
  238. package/skills/gentle-ai/SKILL.md +16 -21
  239. package/skills/issue-creation/SKILL.md +8 -2
  240. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  241. package/skills/work-unit-commits/SKILL.md +4 -3
  242. package/tests/agent-home.test.ts +56 -3
  243. package/tests/agent-model-resolution.test.ts +113 -0
  244. package/tests/agent-profiles.test.ts +30 -0
  245. package/tests/agent-risk-escalation.test.ts +123 -0
  246. package/tests/agents-completion-delivery.test.ts +17 -0
  247. package/tests/agents-fake-child.ts +2 -2
  248. package/tests/agents-message-delivery.test.ts +106 -0
  249. package/tests/agents-protocol.test.ts +97 -0
  250. package/tests/agents-queries.test.ts +9 -9
  251. package/tests/agents-runner-process.test.ts +31 -0
  252. package/tests/agents-runner.test.ts +467 -93
  253. package/tests/agents-view-thread-identity.test.ts +169 -0
  254. package/tests/agents-view.test.ts +8 -2
  255. package/tests/agents-widget.test.ts +154 -15
  256. package/tests/append-system-prompt-route.test.ts +160 -0
  257. package/tests/append-system-prompt.test.ts +46 -0
  258. package/tests/artifact-language.test.ts +19 -213
  259. package/tests/ask-user-question.test.ts +44 -1
  260. package/tests/asset-installation-runtime.test.ts +5 -16
  261. package/tests/auto-mode-invariants.test.ts +258 -0
  262. package/tests/auto-mode-sdk.test.ts +57 -0
  263. package/tests/auto-mode.test.ts +49 -0
  264. package/tests/auto-permission-classifier.test.ts +283 -0
  265. package/tests/auto-permission-runtime.test.ts +196 -0
  266. package/tests/autonomous-guard.test.ts +69 -1
  267. package/tests/background-cache-warming.test.ts +2 -1
  268. package/tests/background-jobs.test.ts +227 -0
  269. package/tests/background-monitor.test.ts +195 -0
  270. package/tests/background-subagents-default-mode.test.ts +28 -5
  271. package/tests/bounded-stop-contract.test.ts +85 -0
  272. package/tests/bounded-writer-admission.test.ts +146 -0
  273. package/tests/branch-pr-skill.test.ts +43 -0
  274. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  275. package/tests/bridge-wake-rendering.test.ts +56 -0
  276. package/tests/build-distribution.test.ts +269 -0
  277. package/tests/builtin-codemode-optout.test.ts +242 -0
  278. package/tests/bundled-install.test.ts +1319 -0
  279. package/tests/bundled-wizard.test.ts +505 -0
  280. package/tests/card-content-policy.test.ts +68 -0
  281. package/tests/card-style-policy.test.ts +87 -0
  282. package/tests/chained-pr-skill.test.ts +124 -0
  283. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  284. package/tests/child-context-files.test.ts +255 -0
  285. package/tests/child-package-entrypoints.test.ts +65 -0
  286. package/tests/child-package-injection.test.ts +74 -0
  287. package/tests/child-safety.test.ts +94 -0
  288. package/tests/child-web-package.test.ts +224 -0
  289. package/tests/codegraph-tools.test.ts +35 -0
  290. package/tests/codemode-rendering.test.ts +596 -0
  291. package/tests/command-palette.test.ts +43 -7
  292. package/tests/context-threshold.test.ts +31 -0
  293. package/tests/delegate-for-reason-contract.test.ts +327 -0
  294. package/tests/delegated-key-learnings-contract.test.ts +3 -78
  295. package/tests/destructive-command-guard.test.ts +84 -0
  296. package/tests/devbinary/native-review-parity.devtest.ts +171 -3
  297. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  298. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  299. package/tests/engram-safety.test.ts +288 -0
  300. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  301. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  302. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  303. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  304. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  305. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  306. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  307. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  308. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  309. package/tests/generic-agent-tools.test.ts +69 -0
  310. package/tests/gentle-agents.test.ts +2804 -346
  311. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  312. package/tests/gentle-ai-binary.test.ts +21 -4
  313. package/tests/gentle-ai-child-guards.test.ts +202 -0
  314. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  315. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  316. package/tests/gentle-ai-installer.test.ts +566 -62
  317. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  318. package/tests/gentle-ai-renderer.test.ts +493 -14
  319. package/tests/gentle-ai.test.ts +1427 -236
  320. package/tests/gentle-card-text.ts +2 -1
  321. package/tests/gentle-herdr-activity.test.ts +100 -0
  322. package/tests/gentle-notifications.test.ts +427 -0
  323. package/tests/gentle-shell-bin.test.ts +896 -115
  324. package/tests/gentle-shell-bundled.test.ts +1002 -0
  325. package/tests/gentle-shell-launcher.test.ts +333 -65
  326. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  327. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  328. package/tests/gentle-shell-startup-update.test.ts +85 -0
  329. package/tests/gentle-shell-uninstall.test.ts +978 -0
  330. package/tests/gentle-shell-update-panel.test.ts +158 -0
  331. package/tests/gentle-shell.test.ts +4216 -119
  332. package/tests/gentle-stats.test.ts +152 -0
  333. package/tests/gentle-theme.test.ts +4 -1
  334. package/tests/gentle-todo.test.ts +333 -10
  335. package/tests/herdr-activity.test.ts +243 -0
  336. package/tests/history-agent-home.test.ts +81 -0
  337. package/tests/history-atomic-write.test.ts +57 -0
  338. package/tests/history-capture-policy.test.ts +102 -0
  339. package/tests/history-child-guard.test.ts +70 -0
  340. package/tests/history-command-registration.test.ts +164 -0
  341. package/tests/history-dedupe-entries.test.ts +123 -0
  342. package/tests/history-delete-backfill.test.ts +190 -0
  343. package/tests/history-delete-confirm.test.ts +460 -0
  344. package/tests/history-dispatch.test.ts +180 -0
  345. package/tests/history-drain-hidden.test.ts +110 -0
  346. package/tests/history-drain-order.test.ts +98 -0
  347. package/tests/history-expanded-globals.test.ts +62 -0
  348. package/tests/history-gc.test.ts +832 -0
  349. package/tests/history-header-layout.test.ts +265 -0
  350. package/tests/history-hide-prompts.test.ts +275 -0
  351. package/tests/history-lazy-windowing.test.ts +508 -0
  352. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  353. package/tests/history-load-shared-history.test.ts +53 -0
  354. package/tests/history-max-results-cap.test.ts +76 -0
  355. package/tests/history-multi-reader.test.ts +203 -0
  356. package/tests/history-off-path.test.ts +170 -0
  357. package/tests/history-openflow-integration.test.ts +173 -0
  358. package/tests/history-overlay-margin.test.ts +326 -0
  359. package/tests/history-preview-layout.test.ts +93 -0
  360. package/tests/history-registry.test.ts +143 -0
  361. package/tests/history-scope-delete.test.ts +411 -0
  362. package/tests/history-search-caret-keys.test.ts +142 -0
  363. package/tests/history-seed-bootstrap.test.ts +170 -0
  364. package/tests/history-seed-regen.test.ts +129 -0
  365. package/tests/history-selector-windowing.test.ts +94 -0
  366. package/tests/history-session-scan-directory.test.ts +87 -0
  367. package/tests/history-session-scan-extract.test.ts +583 -0
  368. package/tests/history-session-writer.test.ts +351 -0
  369. package/tests/history-store-paths.test.ts +79 -0
  370. package/tests/history-tombstone-exact.test.ts +139 -0
  371. package/tests/history-wheel-mouse.test.ts +242 -0
  372. package/tests/inprocess-reviewer.test.ts +49 -19
  373. package/tests/install-gentle-ai-failure.test.ts +69 -0
  374. package/tests/install-tui-mode-setting.test.ts +15 -0
  375. package/tests/install-wizard.test.ts +1223 -0
  376. package/tests/installer-bundles.test.ts +122 -0
  377. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  378. package/tests/installer-preflight.test.ts +750 -0
  379. package/tests/installer-probes.test.ts +1106 -0
  380. package/tests/installer-runner.test.ts +2158 -0
  381. package/tests/installer-server.test.ts +1567 -0
  382. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  383. package/tests/issue-creation-skill.test.ts +61 -0
  384. package/tests/jobs-view.test.ts +160 -0
  385. package/tests/main-channel-upgrade.test.ts +389 -0
  386. package/tests/main-channel.test.ts +344 -0
  387. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  388. package/tests/model-price-ratio.test.ts +116 -0
  389. package/tests/model-profiles-preservation.test.ts +153 -0
  390. package/tests/model-routing-authority.test.ts +16 -0
  391. package/tests/model-routing-package.test.ts +10 -0
  392. package/tests/nan-provider-runtime.test.ts +156 -0
  393. package/tests/nan-provider.test.ts +481 -0
  394. package/tests/native-binary-gate.test.ts +2 -0
  395. package/tests/native-review-capability-contract.test.ts +13 -1
  396. package/tests/native-review-cli.test.ts +239 -118
  397. package/tests/native-review-consent.test.ts +2 -2
  398. package/tests/native-review-parity-runtime.test.ts +101 -4
  399. package/tests/native-review-parity.test.ts +12 -48
  400. package/tests/native-risk-evidence-parity.test.ts +21 -0
  401. package/tests/no-self-pause-contract.test.ts +87 -0
  402. package/tests/notification-audio-native.test.ts +281 -0
  403. package/tests/notification-audio-windows.test.ts +581 -0
  404. package/tests/notification-audio.test.ts +548 -0
  405. package/tests/notification-customize.test.ts +655 -0
  406. package/tests/notification-events.test.ts +115 -0
  407. package/tests/notification-policy.test.ts +201 -0
  408. package/tests/notification-pulse-client.test.ts +356 -0
  409. package/tests/notification-pulse-protocol.test.ts +213 -0
  410. package/tests/notification-pulse-stream.test.ts +320 -0
  411. package/tests/notification-scheduler.test.ts +258 -0
  412. package/tests/notification-ui.test.ts +157 -0
  413. package/tests/npm-pack-result.test.ts +41 -0
  414. package/tests/odd-integration.test.ts +76 -0
  415. package/tests/odd-phase-inference.test.ts +213 -0
  416. package/tests/odd-phase-loader.test.ts +253 -0
  417. package/tests/odd-phase.test.ts +307 -0
  418. package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
  419. package/tests/odd-routing-contract.test.ts +158 -50
  420. package/tests/orchestrator-budget.test.ts +43 -45
  421. package/tests/orchestrator-catalog.test.ts +146 -0
  422. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  423. package/tests/orchestrator-consultation.test.ts +111 -0
  424. package/tests/orchestrator-discovery.test.ts +241 -0
  425. package/tests/orchestrator-helper-consent.test.ts +171 -0
  426. package/tests/orchestrator-helper.test.ts +297 -0
  427. package/tests/orchestrator-message-card.test.ts +190 -0
  428. package/tests/orchestrator-presence.test.ts +23 -0
  429. package/tests/orchestrator-rdd-ownership.test.ts +8 -7
  430. package/tests/orchestrator-scope.test.ts +107 -0
  431. package/tests/orchestrator-state.test.ts +114 -0
  432. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  433. package/tests/orchestrator-tool-card.test.ts +398 -0
  434. package/tests/orchestrator-work-search.test.ts +318 -0
  435. package/tests/orchestrator-work.test.ts +147 -0
  436. package/tests/overlay-repaint.test.ts +74 -0
  437. package/tests/package-manifest.test.ts +344 -123
  438. package/tests/packed-runner-owned-path.test.ts +80 -0
  439. package/tests/parallel-reason-contract.test.ts +21 -0
  440. package/tests/parallel-writer-admission.test.ts +210 -0
  441. package/tests/permission-mode-policy.test.ts +194 -0
  442. package/tests/persona-single-channel.test.ts +10 -18
  443. package/tests/pi-pretty.test.ts +56 -0
  444. package/tests/provider-defect-handoff.test.ts +7 -14
  445. package/tests/quiet-bash-runtime.test.ts +76 -0
  446. package/tests/quiet-tool-rendering.test.ts +569 -187
  447. package/tests/rdd-aware-verification-contract.test.ts +103 -40
  448. package/tests/rdd-status-line.test.ts +9 -4
  449. package/tests/render-cache-performance.test.ts +48 -0
  450. package/tests/resume-hint-extension.test.ts +122 -0
  451. package/tests/review-agent-end-preflight.test.ts +237 -21
  452. package/tests/review-candidate-owner-retry.test.ts +22 -1
  453. package/tests/review-candidate-view.test.ts +399 -12
  454. package/tests/review-contract-prompt.test.ts +138 -44
  455. package/tests/review-controller-lock-status.test.ts +0 -1
  456. package/tests/review-controller-native-recovery.test.ts +327 -17
  457. package/tests/review-controller-native-routing.test.ts +866 -6
  458. package/tests/review-controller-workspace-root.test.ts +206 -6
  459. package/tests/review-controller.test.ts +2 -2
  460. package/tests/review-correction-lifecycle.test.ts +2 -1
  461. package/tests/review-gate.test.ts +7 -4
  462. package/tests/review-graph-schema.test.ts +2 -2
  463. package/tests/review-host-relay-routing.test.ts +342 -4
  464. package/tests/review-host-relay.test.ts +49 -2
  465. package/tests/review-integration-v2-forward.test.ts +221 -1
  466. package/tests/review-json-arguments.test.ts +167 -0
  467. package/tests/review-last-event-closure.test.ts +157 -8
  468. package/tests/review-ledger-contract.test.ts +14 -37
  469. package/tests/review-policy-judgment-day.test.ts +1 -1
  470. package/tests/review-policy-ordinary.test.ts +2 -2
  471. package/tests/review-relay-transport-agent.test.ts +66 -1
  472. package/tests/review-reminder-receipt.test.ts +47 -1
  473. package/tests/review-risk-assessment.test.ts +594 -26
  474. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  475. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  476. package/tests/review-sidebar-state.test.ts +402 -0
  477. package/tests/review-test-fixtures.ts +7 -1
  478. package/tests/review-transaction.test.ts +18 -1
  479. package/tests/run-test-suite.test.ts +202 -0
  480. package/tests/runtime-harness.mjs +186 -800
  481. package/tests/runtime-metrics-children.test.ts +16 -23
  482. package/tests/runtime-metrics-extension.test.ts +1 -1
  483. package/tests/runtime-metrics-native.test.ts +31 -1
  484. package/tests/selection-engine.test.ts +451 -0
  485. package/tests/session-change-capture.test.ts +10 -10
  486. package/tests/session-changes-shell.test.ts +242 -48
  487. package/tests/session-changes.test.ts +17 -24
  488. package/tests/session-messaging-grants.test.ts +255 -0
  489. package/tests/session-profile-binding.test.ts +163 -0
  490. package/tests/session-profile-freeze.test.ts +381 -0
  491. package/tests/session-subject-routing-contract.test.ts +34 -0
  492. package/tests/session-worktree-registry.test.ts +134 -2
  493. package/tests/shell-bar.test.ts +422 -1
  494. package/tests/shell-card.test.ts +353 -1
  495. package/tests/shell-changes-view.test.ts +135 -32
  496. package/tests/shell-changes.test.ts +61 -62
  497. package/tests/shell-prompt.test.ts +94 -2
  498. package/tests/shell-render-cache-runtime.test.ts +69 -0
  499. package/tests/shell-render-cache.test.ts +129 -0
  500. package/tests/shell-sidebar-layout.test.ts +351 -22
  501. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  502. package/tests/shell-todo.test.ts +207 -3
  503. package/tests/shell-usage-view.test.ts +28 -1
  504. package/tests/shell-usage.test.ts +108 -0
  505. package/tests/short-check-routing-contract.test.ts +49 -0
  506. package/tests/skill-registry-watcher.test.ts +119 -0
  507. package/tests/skill-registry.test.ts +357 -2
  508. package/tests/startup-banner.test.ts +246 -4
  509. package/tests/startup-home-repo.test.ts +170 -0
  510. package/tests/stats-collector.test.ts +195 -0
  511. package/tests/stats-view.test.ts +202 -0
  512. package/tests/status-timing-diagnostics.test.ts +283 -0
  513. package/tests/support/orchestrator-modules.ts +19 -0
  514. package/tests/task-size-routing-contract.test.ts +241 -0
  515. package/tests/telemetry-trigger.test.ts +107 -20
  516. package/tests/test-strength-contract.test.ts +124 -0
  517. package/tests/theme-customization.test.ts +72 -0
  518. package/tests/verify-agent-contract.test.ts +90 -0
  519. package/tests/verify-first-launch-contract.test.ts +139 -0
  520. package/tests/verify-package-files.test.ts +56 -0
  521. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  522. package/tests/vim-editor-adapter.test.ts +804 -0
  523. package/tests/vim-normal-engine.test.ts +101 -0
  524. package/tests/vim-operator-engine.test.ts +215 -0
  525. package/tests/vim-policy.test.ts +19 -0
  526. package/tests/vim-visual-engine.test.ts +52 -0
  527. package/tests/visual-customization-policy.test.ts +139 -0
  528. package/tests/visual-customize-view.test.ts +696 -0
  529. package/tests/visual-profiles.test.ts +87 -0
  530. package/tests/writer-edit-surface-scope.test.ts +59 -2
  531. package/tests/writer-surfaces.test.ts +199 -0
  532. package/tests/yolo-customize.test.ts +271 -0
  533. package/tests/yolo-mode-runtime.test.ts +188 -0
  534. package/tests/yolo-mode.test.ts +289 -0
  535. package/tests/yolo-session-policy.test.ts +59 -0
  536. package/themes/Gentle.json +2 -1
  537. package/themes/Gentleman-Cute.json +2 -1
  538. package/themes/Gentleman-Sexy.json +2 -1
  539. package/assets/agents/sdd-apply.md +0 -159
  540. package/assets/agents/sdd-archive.md +0 -228
  541. package/assets/agents/sdd-design.md +0 -49
  542. package/assets/agents/sdd-explore.md +0 -48
  543. package/assets/agents/sdd-init.md +0 -56
  544. package/assets/agents/sdd-onboard.md +0 -52
  545. package/assets/agents/sdd-proposal.md +0 -64
  546. package/assets/agents/sdd-remediate.md +0 -37
  547. package/assets/agents/sdd-research.md +0 -49
  548. package/assets/agents/sdd-spec.md +0 -192
  549. package/assets/agents/sdd-status.md +0 -54
  550. package/assets/agents/sdd-tasks.md +0 -108
  551. package/assets/agents/sdd-verify.md +0 -124
  552. package/assets/chains/sdd-full.chain.md +0 -83
  553. package/assets/chains/sdd-plan.chain.md +0 -56
  554. package/assets/chains/sdd-verify.chain.md +0 -43
  555. package/assets/sdd-orchestrator-workflow.md +0 -319
  556. package/assets/support/sdd-status-contract.md +0 -77
  557. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  558. package/extensions/sdd-init.ts +0 -816
  559. package/lib/openspec-deltas.ts +0 -156
  560. package/lib/sdd-preflight.ts +0 -1066
  561. package/lib/sdd-research-capabilities.ts +0 -94
  562. package/lib/sdd-status.ts +0 -26
  563. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  564. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  565. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  566. package/tests/openspec-deltas.test.ts +0 -209
  567. package/tests/sdd-agent-tools.test.ts +0 -156
  568. package/tests/sdd-archive-replay.test.ts +0 -82
  569. package/tests/sdd-classical-continuation.test.ts +0 -74
  570. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  571. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  572. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  573. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  574. package/tests/sdd-odd-integration.test.ts +0 -33
  575. package/tests/sdd-optional-research.test.ts +0 -124
  576. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  577. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  578. package/tests/sdd-preflight.test.ts +0 -541
  579. package/tests/sdd-research-capabilities.test.ts +0 -114
  580. package/tests/sdd-research-live.test.ts +0 -241
  581. package/tests/sdd-selection-transport.test.ts +0 -653
  582. package/tests/sdd-status.test.ts +0 -9
  583. package/tests/sdd-task-truth.test.ts +0 -43
  584. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  585. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  586. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  587. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  588. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  589. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  590. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,1223 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { request as httpRequest } from "node:http";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+ import * as wizard from "../assets/install-wizard/wizard.js";
8
+ import { planPreflight, requirements } from "../scripts/installer-preflight.mjs";
9
+ import { blockedReasons, failedSteps } from "../scripts/installer-runner.mjs";
10
+ import { createInstallerServer, guidance } from "../scripts/installer-server.mjs";
11
+ import { scenarioNames, startPreview } from "../scripts/install-wizard-preview.mjs";
12
+ import { prefixLayout } from "../scripts/bundled-install.mjs";
13
+ import { bundledFailedSteps, bundledPlan, runBundledInstall } from "../scripts/bundled-wizard.mjs";
14
+
15
+ const assetsDir = fileURLToPath(new URL("../assets/install-wizard/", import.meta.url));
16
+ const read = (name: string) => readFileSync(join(assetsDir, name), "utf8");
17
+ const API = { "x-gentle-install": "1" };
18
+ const BIN = "/home/u/.local/share/pnpm/bin";
19
+ // Backtick segments render as <code> elements, so rendered text has no backticks.
20
+ const plain = (value: string) => value.replaceAll("`", "");
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Real HTTP helpers (the real host serving the real assets)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ type Response = { status: number; headers: Record<string, string | string[] | undefined>; body: string };
27
+ function send(port: number, { method = "GET", path = "/", headers = {} as Record<string, string>, body = undefined as string | undefined } = {}):
28
+ Promise<Response> {
29
+ return new Promise((resolve, reject) => {
30
+ const all: Record<string, string | number> = { host: `127.0.0.1:${port}`, ...headers };
31
+ if (body !== undefined) all["content-length"] = Buffer.byteLength(body);
32
+ const req = httpRequest({ host: "127.0.0.1", port, method, path, headers: all, setHost: false, agent: false }, (res) => {
33
+ const chunks: Buffer[] = [];
34
+ res.on("data", (chunk) => chunks.push(chunk));
35
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") }));
36
+ });
37
+ req.on("error", reject);
38
+ if (body !== undefined) req.write(body);
39
+ req.end();
40
+ });
41
+ }
42
+ async function login(port: number, url: string) {
43
+ const code = new URL(url).searchParams.get("code") ?? "";
44
+ const response = await send(port, { path: `/session?code=${code}` });
45
+ assert.equal(response.status, 200);
46
+ return String(response.headers["set-cookie"]).split(";")[0];
47
+ }
48
+ function post(port: number, path: string, cookie: string, body: unknown) {
49
+ return send(port, { method: "POST", path, body: JSON.stringify(body), headers: { cookie, origin: `http://127.0.0.1:${port}`,
50
+ "content-type": "application/json", ...API } });
51
+ }
52
+ async function getJson(port: number, path: string, cookie: string) {
53
+ const response = await send(port, { path, headers: { cookie, ...API } });
54
+ return { status: response.status, body: JSON.parse(response.body) };
55
+ }
56
+
57
+ const cleanInventory = {
58
+ platform: "linux",
59
+ arch: "x64",
60
+ node: { available: true, version: "24.18.0", usable: true, persistent: false, npm: false },
61
+ pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false },
62
+ pi: { available: false },
63
+ shell: { available: false },
64
+ gentleAi: { available: false },
65
+ go: { available: false },
66
+ globalBin: { available: true, path: BIN, writable: true, onPath: false },
67
+ setup: false,
68
+ };
69
+
70
+ /** The real host's /api/plan view for an inventory, so model tests never drift from the server. */
71
+ async function serverPlanView(changes: Record<string, unknown> = {}, channel = "release") {
72
+ const inventory = { ...cleanInventory, ...changes };
73
+ const host = createInstallerServer({ assetsDir, collectPlan: async (requested: string) => ({ inventory, plan: planPreflight(inventory, { channel: requested }) }),
74
+ runInstall: async () => ({ outcome: "ready", completed: [] }) });
75
+ const { port, url } = await host.listen();
76
+ try {
77
+ const cookie = await login(port, url);
78
+ const { status, body } = await getJson(port, `/api/plan?channel=${channel}`, cookie);
79
+ assert.equal(status, 200);
80
+ return body;
81
+ } finally {
82
+ await host.close("test");
83
+ }
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Minimal fake DOM: only the APIs wizard.js may use. innerHTML throws.
88
+ // ---------------------------------------------------------------------------
89
+
90
+ class FakeText {
91
+ data: string;
92
+ parent: FakeElement | null = null;
93
+ constructor(data: string) {
94
+ this.data = data;
95
+ }
96
+ get textContent() {
97
+ return this.data;
98
+ }
99
+ }
100
+ class FakeElement {
101
+ tagName: string;
102
+ ownerDocument: FakeDocument;
103
+ attributes = new Map<string, string>();
104
+ children: Array<FakeElement | FakeText> = [];
105
+ listeners = new Map<string, Array<(event: unknown) => unknown>>();
106
+ parent: FakeElement | null = null;
107
+ checked = false;
108
+ disabled = false;
109
+ constructor(ownerDocument: FakeDocument, tag: string) {
110
+ this.ownerDocument = ownerDocument;
111
+ this.tagName = tag.toUpperCase();
112
+ }
113
+ get textContent(): string {
114
+ return this.children.map((child) => child.textContent).join("");
115
+ }
116
+ set textContent(value: string) {
117
+ this.replaceChildren(String(value));
118
+ }
119
+ set innerHTML(_value: string) {
120
+ throw new Error("innerHTML is forbidden");
121
+ }
122
+ set outerHTML(_value: string) {
123
+ throw new Error("outerHTML is forbidden");
124
+ }
125
+ insertAdjacentHTML() {
126
+ throw new Error("insertAdjacentHTML is forbidden");
127
+ }
128
+ get id() {
129
+ return this.attributes.get("id") ?? "";
130
+ }
131
+ append(...nodes: Array<FakeElement | FakeText | string>) {
132
+ for (const node of nodes) {
133
+ const child = typeof node === "string" ? new FakeText(node) : node;
134
+ child.parent?.children.splice(child.parent.children.indexOf(child), 1);
135
+ child.parent = this;
136
+ this.children.push(child);
137
+ }
138
+ }
139
+ appendChild(node: FakeElement | FakeText) {
140
+ this.append(node);
141
+ return node;
142
+ }
143
+ replaceChildren(...nodes: Array<FakeElement | FakeText | string>) {
144
+ for (const child of this.children) child.parent = null;
145
+ this.children = [];
146
+ this.append(...nodes);
147
+ }
148
+ remove() {
149
+ this.parent?.children.splice(this.parent.children.indexOf(this), 1);
150
+ this.parent = null;
151
+ }
152
+ setAttribute(name: string, value: string) {
153
+ if (name === "style" || /^on/i.test(name)) throw new Error(`forbidden attribute ${name}`);
154
+ this.attributes.set(name, String(value));
155
+ }
156
+ getAttribute(name: string) {
157
+ return this.attributes.get(name) ?? null;
158
+ }
159
+ hasAttribute(name: string) {
160
+ return this.attributes.has(name);
161
+ }
162
+ removeAttribute(name: string) {
163
+ this.attributes.delete(name);
164
+ }
165
+ addEventListener(type: string, listener: (event: unknown) => unknown) {
166
+ this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
167
+ }
168
+ focus() {
169
+ this.ownerDocument.activeElement = this;
170
+ }
171
+ }
172
+ class FakeDocument {
173
+ activeElement: FakeElement | null = null;
174
+ roots = new Map<string, FakeElement>();
175
+ constructor() {
176
+ for (const id of ["view", "stages", "alert", "status"]) {
177
+ const root = new FakeElement(this, "div");
178
+ root.setAttribute("id", id);
179
+ this.roots.set(id, root);
180
+ }
181
+ }
182
+ createElement(tag: string) {
183
+ return new FakeElement(this, tag);
184
+ }
185
+ createTextNode(text: string) {
186
+ return new FakeText(text);
187
+ }
188
+ getElementById(id: string) {
189
+ return this.roots.get(id) ?? null;
190
+ }
191
+ }
192
+
193
+ function walk(node: FakeElement | FakeText, out: FakeElement[] = []) {
194
+ if (node instanceof FakeElement) {
195
+ out.push(node);
196
+ for (const child of node.children) walk(child, out);
197
+ }
198
+ return out;
199
+ }
200
+ const all = (root: FakeElement, tag: string) => walk(root).filter((element) => element.tagName === tag.toUpperCase());
201
+ const button = (root: FakeElement, text: string) => all(root, "button").find((element) => element.textContent.includes(text));
202
+ const headings = (root: FakeElement) => all(root, "h1");
203
+ function dispatch(element: FakeElement, type: string) {
204
+ for (const listener of element.listeners.get(type) ?? []) listener({ type, target: element, preventDefault() {} });
205
+ }
206
+ async function flush() {
207
+ for (let i = 0; i < 30; i += 1) await new Promise((done) => setImmediate(done));
208
+ }
209
+
210
+ type Timer = { fn: () => void; ms: number; cleared: boolean; ran: boolean };
211
+ function fakeTimers() {
212
+ const queue: Timer[] = [];
213
+ const pending = () => queue.filter((timer) => !timer.cleared && !timer.ran);
214
+ return {
215
+ setTimeout: (fn: () => void, ms: number) => {
216
+ const timer = { fn, ms, cleared: false, ran: false };
217
+ queue.push(timer);
218
+ return timer;
219
+ },
220
+ clearTimeout: (timer: Timer | undefined) => {
221
+ if (timer) timer.cleared = true;
222
+ },
223
+ pending,
224
+ async runNext() {
225
+ const [timer] = pending();
226
+ assert.ok(timer, "a timer is scheduled");
227
+ timer.ran = true;
228
+ timer.fn();
229
+ await flush();
230
+ return timer;
231
+ },
232
+ };
233
+ }
234
+
235
+ type Call = { url: string; method: string; headers: Record<string, string>; body: unknown };
236
+ type Reply = [number, unknown] | Error;
237
+ function fakeFetch(handlers: Record<string, (call: Call) => Reply | Promise<Reply>>) {
238
+ const calls: Call[] = [];
239
+ const fetch = async (url: string, init: { method?: string; headers?: Record<string, string>; body?: string } = {}) => {
240
+ const call = { url, method: init.method ?? "GET", headers: init.headers ?? {}, body: init.body === undefined ? undefined : JSON.parse(init.body) };
241
+ calls.push(call);
242
+ const handler = handlers[`${call.method} ${url.split("?")[0]}`];
243
+ assert.ok(handler, `unexpected request ${call.method} ${url}`);
244
+ const result = await handler(call);
245
+ if (result instanceof Error) throw result;
246
+ const [status, body] = result;
247
+ return { status, ok: status >= 200 && status < 300, json: async () => body };
248
+ };
249
+ return { fetch, calls };
250
+ }
251
+
252
+ type Clipboard = { writeText: (value: string) => Promise<void> } | null;
253
+ function mount(handlers: Record<string, (call: Call) => Reply | Promise<Reply>>, { clipboard = null as Clipboard } = {}) {
254
+ const document = new FakeDocument();
255
+ const timers = fakeTimers();
256
+ const server = fakeFetch(handlers);
257
+ const app = wizard.createWizard({ document, fetch: server.fetch, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout, clipboard });
258
+ const view = document.roots.get("view") as FakeElement;
259
+ return { document, timers, server, app, view, alert: document.roots.get("alert") as FakeElement,
260
+ status: document.roots.get("status") as FakeElement };
261
+ }
262
+ const idleProgress = (): Reply => [200, { entries: [], running: false, outcome: null }];
263
+ const outcomeProgress = (outcome: Record<string, unknown>): Reply => [200, { entries: [], running: false, outcome }];
264
+ const readyOutcome = { outcome: "ready", completed: ["install-global"], guidance: guidance.outcomes.ready };
265
+ /** Ticks the consent checkbox on the review screen and presses Install. */
266
+ async function consentAndInstall(ui: ReturnType<typeof mount>) {
267
+ const consent = all(ui.view, "input").find((input) => input.getAttribute("type") === "checkbox") as FakeElement;
268
+ consent.checked = true;
269
+ dispatch(consent, "change");
270
+ dispatch(button(ui.view, "Install") as FakeElement, "click");
271
+ await flush();
272
+ }
273
+ const posts = (ui: ReturnType<typeof mount>, path: string) => ui.server.calls.filter((call) => call.method === "POST" && call.url === path).length;
274
+ const progressChecks = (ui: ReturnType<typeof mount>) => ui.server.calls.filter((call) => call.url.startsWith("/api/progress")).length;
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Static assets under the strict CSP
278
+ // ---------------------------------------------------------------------------
279
+
280
+ test("index.html loads only same-origin wizard.js and wizard.css, with no inline script, style or handlers", () => {
281
+ const html = read("index.html");
282
+ assert.match(html, /<script type="module" src="\/wizard\.js"><\/script>/);
283
+ assert.match(html, /<link rel="stylesheet" href="\/wizard\.css">/);
284
+ assert.equal([...html.matchAll(/<script\b/gi)].length, 1, "only the module script tag");
285
+ assert.doesNotMatch(html, /<style\b|\sstyle=|\son[a-z]+=|javascript:/i);
286
+ assert.doesNotMatch(html, /https?:\/\/|\/\/[a-z]|\bsrc="(?!\/wizard\.js)|\bhref="(?!\/wizard\.css|#)/i);
287
+ // Landmarks, one h1 at load, and live regions present before any update.
288
+ assert.equal([...html.matchAll(/<h1\b/g)].length, 1);
289
+ for (const landmark of ["<header", "<main", "<footer"]) assert.ok(html.includes(landmark), landmark);
290
+ assert.match(html, /id="status"[^>]*role="status"|role="status"[^>]*id="status"/);
291
+ assert.match(html, /id="alert"[^>]*role="alert"|role="alert"[^>]*id="alert"/);
292
+ assert.match(html, /<html lang="en">/);
293
+ assert.match(html, /<noscript>/);
294
+ });
295
+
296
+ test("wizard.js and wizard.css use no HTML injection, dynamic code, inline styles or external resources", () => {
297
+ const js = read("wizard.js");
298
+ for (const forbidden of [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new\s+Function\b/,
299
+ /setAttribute\(\s*["']style["']/, /\.style\b/, /https?:\/\//, /\bimport\s*\(/, /setTimeout\(\s*["'`]/]) {
300
+ assert.doesNotMatch(js, forbidden, String(forbidden));
301
+ }
302
+ const css = read("wizard.css");
303
+ assert.doesNotMatch(css, /@import|url\(|https?:\/\/|@font-face/i);
304
+ assert.match(css, /prefers-reduced-motion/);
305
+ assert.match(css, /:focus-visible/);
306
+ // Only the programmatically focused heading (tabindex=-1, never interactive) drops its outline;
307
+ // every control keeps the :focus-visible ring.
308
+ const rules = css.replace(/\/\*[\s\S]*?\*\//g, "");
309
+ const outlineOff = [...rules.matchAll(/([^{}]+)\{[^{}]*outline:\s*(?:none|0)\b[^{}]*\}/g)].map((match) => match[1].trim());
310
+ assert.deepEqual(outlineOff, ['h1[tabindex="-1"]:focus']);
311
+ assert.doesNotMatch(rules, /h1[^{,]*:focus-visible/);
312
+ });
313
+
314
+ test("the reference hero glow sits behind the panel as a decorative, static pseudo-element", () => {
315
+ const css = read("wizard.css").replace(/\s+/g, " ").replace(/\s*,\s*/g, ",");
316
+ const rule = css.match(/\.view::before \{([^}]*)\}/)?.[1] ?? "";
317
+ assert.ok(rule.includes("radial-gradient(38% 50% at 30% 30%,#f095c838,#0000 70%),radial-gradient(30% 40% at 75% 40%,#b4e7c71f,#0000 70%)"),
318
+ "reference gradients");
319
+ assert.match(rule, /pointer-events: none/);
320
+ assert.match(rule, /filter: blur\(/);
321
+ assert.match(rule, /z-index: -1/);
322
+ assert.doesNotMatch(rule, /animation|transition/, "decorative glow never moves");
323
+ });
324
+
325
+ test("the real host serves the real wizard assets under the strict CSP", async () => {
326
+ const host = createInstallerServer({ assetsDir, collectPlan: async () => ({ inventory: cleanInventory, plan: planPreflight(cleanInventory) }),
327
+ runInstall: async () => ({ outcome: "ready", completed: [] }) });
328
+ const { port, url } = await host.listen();
329
+ try {
330
+ const cookie = await login(port, url);
331
+ for (const [path, type] of [["/", "text/html"], ["/wizard.js", "text/javascript"], ["/wizard.css", "text/css"]]) {
332
+ const response = await send(port, { path, headers: { cookie } });
333
+ assert.equal(response.status, 200, path);
334
+ assert.match(String(response.headers["content-type"]), new RegExp(`^${type}`), path);
335
+ assert.match(String(response.headers["content-security-policy"]), /^default-src 'self';/);
336
+ }
337
+ } finally {
338
+ await host.close("test");
339
+ }
340
+ });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Pure models
344
+ // ---------------------------------------------------------------------------
345
+
346
+ test("planModel discloses the PATH change and runtime persistence before consent", async () => {
347
+ const view = await serverPlanView();
348
+ const model = wizard.planModel(view);
349
+ assert.equal(model.kind, "install");
350
+ assert.deepEqual(model.actions.map((action: { id: string }) => action.id), view.actions.map((action: { id: string }) => action.id));
351
+ const [profile, persistence] = model.disclosures;
352
+ assert.equal(profile.id, "profile");
353
+ assert.equal(profile.changes, true);
354
+ assert.equal(profile.text, view.profileChange.description);
355
+ assert.equal(profile.detail, BIN);
356
+ assert.match(profile.state, /will change/i);
357
+ assert.equal(persistence.id, "persistence");
358
+ assert.equal(persistence.changes, true);
359
+ assert.equal(persistence.text, view.persistence.description);
360
+ assert.match(persistence.state, /node, npm, pnpm/);
361
+
362
+ const unchanged = wizard.planModel(await serverPlanView({ node: { ...cleanInventory.node, persistent: true, npm: true },
363
+ pnpm: { ...cleanInventory.pnpm, persistent: true }, globalBin: { ...cleanInventory.globalBin, onPath: true } }));
364
+ assert.equal(unchanged.kind, "install");
365
+ assert.deepEqual(unchanged.disclosures.map((item: { changes: boolean; state: string }) => [item.changes, /no change/i.test(item.state)]),
366
+ [[false, true], [false, true]]);
367
+ });
368
+
369
+ // A missing Git is noted on the review screen, never as a blocker: the plan stays installable.
370
+ test("the review screen notes a missing Git and stays installable", async () => {
371
+ const model = wizard.planModel(await serverPlanView({ git: { available: false } }));
372
+ assert.match(model.gitNotice, /Git was not found on this computer/);
373
+ const node = wizard.renderPlan(new FakeDocument(), model, { install() {}, reload() {}, close() {} });
374
+ assert.match(node.textContent, /Git not found/);
375
+ assert.match(node.textContent, /sudo apt-get install -y git/);
376
+ assert.ok(button(node, "Install Gentle Shell"), "still installable");
377
+ const plain = wizard.planModel(await serverPlanView({ git: { available: true } }));
378
+ assert.equal(plain.gitNotice, null);
379
+ assert.doesNotMatch(wizard.renderPlan(new FakeDocument(), plain, { install() {}, reload() {}, close() {} }).textContent, /Git not found/);
380
+ });
381
+
382
+ // S6 notice: shown on the review screen before consent; it changes nothing else.
383
+ test("the review screen shows the notice about reused tools in folders another account can change", async () => {
384
+ const weak = { check: "parent-acl-mask", at: "C:\\Users\\m\\AppData\\Roaming", sid: "S-1-5-21-1-2-3-1002", account: "PC\\other", rights: "0x001301BF" };
385
+ const windows = { platform: "win32", node: { ...cleanInventory.node, persistent: true, npm: true }, pnpm: { ...cleanInventory.pnpm, persistent: true },
386
+ go: { available: true, version: "1.26.0", usable: true } };
387
+ const view = await serverPlanView({ ...windows, folders: { node: weak, npm: weak } });
388
+ const model = wizard.planModel(view);
389
+ assert.equal(model.kind, "install");
390
+ assert.equal(model.notice, view.sharedFolders.description);
391
+ assert.deepEqual(model.disclosures.map((item: { id: string }) => item.id), ["profile", "persistence"]);
392
+ const node = wizard.renderPlan(new FakeDocument(), model, { install() {}, reload() {}, close() {} });
393
+ assert.match(node.textContent, /Tools in folders other accounts can change/);
394
+ assert.ok(node.textContent.includes("S-1-5-21-1-2-3-1002 (PC\\other) can change C:\\Users\\m\\AppData\\Roaming"));
395
+ assert.ok(button(node, "Install Gentle Shell"), "still installable");
396
+ const plain = wizard.planModel(await serverPlanView(windows));
397
+ assert.equal(plain.notice, null);
398
+ assert.doesNotMatch(wizard.renderPlan(new FakeDocument(), plain, { install() {}, reload() {}, close() {} }).textContent, /other accounts can change/);
399
+ });
400
+
401
+ test("a recoverable stack is reviewed as completing setup, not as a reinstall", async () => {
402
+ const view = await serverPlanView({ pi: { available: true, version: "1.0.0", usable: true },
403
+ shell: { available: true, version: requirements.shell, usable: true, global: true },
404
+ gentleAi: { available: true, version: requirements.gentleAi, usable: true, compatible: true },
405
+ setup: { available: true, recoverable: true } });
406
+ const model = wizard.planModel(view);
407
+ assert.equal(model.kind, "recovery");
408
+ assert.deepEqual(model.actions.map((action: { id: string }) => action.id), ["setup-global-bin", "setup-shell", "verify-readiness"]);
409
+ assert.equal(model.steps.includes("install-global"), false);
410
+ assert.equal(model.disclosures[1].changes, false);
411
+ const node = wizard.renderPlan(new FakeDocument(), model, { install() {}, reload() {}, close() {} });
412
+ assert.match(node.textContent, /Gentle Shell is already installed; this completes setup/);
413
+ assert.ok(button(node, "Complete setup"));
414
+ assert.equal(button(node, "Install Gentle Shell"), undefined);
415
+ assert.equal(/Review the installation plan|Installation steps/.test(node.textContent), false);
416
+ // A clean plan keeps the installation wording.
417
+ assert.equal(wizard.planModel(await serverPlanView()).kind, "install");
418
+ });
419
+
420
+ test("planModel turns preflight blockers into guidance and offers no install", async () => {
421
+ const model = wizard.planModel(await serverPlanView({ node: { available: true, version: "18.0.0", usable: true, persistent: true, npm: true } }));
422
+ assert.equal(model.kind, "blocked");
423
+ assert.equal(model.blockers.length, 1);
424
+ assert.equal(model.blockers[0].toolLabel, "Node.js");
425
+ assert.equal(model.blockers[0].guidance, "Node.js 18.0.0 is installed, but this installer needs 22.19.0 or newer. Nothing was replaced. Update it, then select Check again.");
426
+ for (const code of Object.keys(guidance.blockers)) {
427
+ const rendered = wizard.planModel({ planId: "p", ready: false, actions: [], blockers: [{ code, tool: "pnpm", guidance: guidance.blockers[code] }],
428
+ profileChange: { changesProfile: false, binDir: null, description: "x" }, persistence: { tools: [], pnpmHome: null, description: "y" } });
429
+ assert.equal(rendered.blockers[0].guidance, guidance.blockers[code]);
430
+ }
431
+ const nothing = wizard.planModel({ planId: "p", ready: true, actions: [{ id: "verify-readiness", description: "Verify." }], blockers: [],
432
+ profileChange: { changesProfile: false, binDir: null, description: "x" }, persistence: { tools: [], pnpmHome: null, description: "y" } });
433
+ assert.equal(nothing.kind, "nothing");
434
+ });
435
+
436
+ test("expectedSteps mirrors the runner sequence and every step has a label", () => {
437
+ const base = ["install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"];
438
+ assert.deepEqual(wizard.expectedSteps(["install-pi", "install-shell", "setup-shell", "verify-readiness"]),
439
+ ["check-npm", "check-global-bin", "check-existing-stack", ...base]);
440
+ assert.deepEqual(wizard.expectedSteps(["setup-global-bin", "persist-node", "persist-package-managers", "configure-npm-prefix", "install-pi",
441
+ "install-shell", "setup-shell", "verify-readiness"]), ["check-global-bin", "check-existing-stack", "persist-node", "persist-package-managers",
442
+ "verify-persistent-runtime", "check-npm", "configure-npm-prefix", ...base, "persist-path"]);
443
+ // Next to a persistent pnpm (never downgraded), a bootstrap-only Node persists only npm with it.
444
+ assert.deepEqual(wizard.expectedSteps(["persist-node", "persist-npm", "configure-npm-prefix", "install-pi", "install-shell", "setup-shell",
445
+ "verify-readiness"]), ["check-global-bin", "check-existing-stack", "persist-node", "persist-npm", "verify-persistent-runtime", "check-npm",
446
+ "configure-npm-prefix", ...base]);
447
+ assert.deepEqual(wizard.expectedSteps(["persist-npm", "install-pi", "install-shell"]),
448
+ ["check-global-bin", "check-existing-stack", "persist-npm", "check-npm", ...base]);
449
+ assert.deepEqual(wizard.expectedSteps(["persist-pnpm", "install-pi", "install-shell"]),
450
+ ["check-npm", "check-global-bin", "check-existing-stack", "persist-pnpm", "verify-persistent-pnpm", ...base]);
451
+ assert.deepEqual(wizard.expectedSteps(["persist-package-managers", "install-pi", "install-shell"]),
452
+ ["check-global-bin", "check-existing-stack", "persist-package-managers", "check-npm", "verify-persistent-pnpm", ...base]);
453
+ // Setup recovery: the installed stack is re-verified, never reinstalled.
454
+ const recovery = ["check-npm", "check-global-bin", "check-recoverable-stack", "verify-global-list", "verify-shell-bin",
455
+ "verify-gentle-ai", "shell-setup"];
456
+ assert.deepEqual(wizard.expectedSteps(["setup-shell", "verify-readiness"]), recovery);
457
+ assert.deepEqual(wizard.expectedSteps(["setup-global-bin", "setup-shell", "verify-readiness"]), [...recovery, "persist-path"]);
458
+ const known = new Set([...failedSteps, "check-global-bin", "check-existing-stack", "check-recoverable-stack"]);
459
+ for (const id of [...known, "gate"]) assert.notEqual(wizard.stepLabel(id), id, `label for ${id}`);
460
+ for (const id of wizard.expectedSteps(["setup-global-bin", "persist-node", "persist-package-managers", "configure-npm-prefix"])) {
461
+ assert.ok(known.has(id), id);
462
+ }
463
+ assert.equal(wizard.stepLabel("not-a-step"), "not-a-step");
464
+ });
465
+
466
+ const mainIds = ["build-gentle-ai-main", "install-shell-main", "record-channel"];
467
+ const goReady = { go: { available: true, version: "1.26.0", usable: true } };
468
+
469
+ test("planModel carries the plan's channel, release unless the host says main", async () => {
470
+ assert.equal(wizard.planModel(await serverPlanView(goReady, "main")).channel, "main");
471
+ assert.equal(wizard.planModel(await serverPlanView()).channel, "release");
472
+ assert.equal(wizard.planModel({ channel: "nightly", actions: [], blockers: [] }).channel, "release");
473
+ });
474
+
475
+ test("expectedSteps places the main steps after the release verification and before setup", () => {
476
+ const verified = ["install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai"];
477
+ assert.deepEqual(wizard.expectedSteps(["install-pi", "install-shell", "setup-shell", "verify-readiness", ...mainIds]),
478
+ ["check-npm", "check-global-bin", "check-existing-stack", ...verified, ...mainIds, "shell-setup"]);
479
+ assert.deepEqual(wizard.expectedSteps(["setup-shell", "verify-readiness", ...mainIds]),
480
+ ["check-npm", "check-global-bin", "check-recoverable-stack", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", ...mainIds, "shell-setup"]);
481
+ for (const id of mainIds) assert.notEqual(wizard.stepLabel(id), id, `label for ${id}`);
482
+ });
483
+
484
+ test("expectedSteps runs the pinned Go acquisition after the pre-install checks and before anything is installed", () => {
485
+ const go = ["acquire-go", "verify-go"];
486
+ assert.deepEqual(wizard.expectedSteps([...go, "install-pi", "install-shell", "setup-shell", "verify-readiness", ...mainIds]),
487
+ ["check-npm", "check-global-bin", "check-existing-stack", ...go, "install-global", "verify-global-list", "verify-shell-bin",
488
+ "verify-gentle-ai", ...mainIds, "shell-setup"]);
489
+ // Like the runner, the pinned Go comes before the runtime is persisted.
490
+ assert.deepEqual(wizard.expectedSteps(["setup-global-bin", ...go, "persist-node", "persist-package-managers", "configure-npm-prefix",
491
+ "install-pi", "install-shell", "setup-shell", "verify-readiness", ...mainIds]),
492
+ ["check-global-bin", "check-existing-stack", ...go, "persist-node", "persist-package-managers", "verify-persistent-runtime", "check-npm",
493
+ "configure-npm-prefix", "install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", ...mainIds, "shell-setup", "persist-path"]);
494
+ assert.deepEqual(wizard.expectedSteps([...go, "persist-pnpm", "install-pi", "install-shell", "setup-shell", "verify-readiness"]),
495
+ ["check-npm", "check-global-bin", "check-existing-stack", ...go, "persist-pnpm", "verify-persistent-pnpm", "install-global",
496
+ "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]);
497
+ assert.deepEqual(wizard.expectedSteps([...go, "update-shell-main", "setup-shell", "verify-readiness"]),
498
+ ["check-npm", "check-global-bin", "check-installed-shell", ...go, "update-shell", "verify-updated-shell", "shell-setup"]);
499
+ for (const id of go) assert.notEqual(wizard.stepLabel(id), id, `label for ${id}`);
500
+ });
501
+
502
+ test("the review screen offers the release and main channels and reloads the plan for the chosen one", async () => {
503
+ const release = await serverPlanView(goReady);
504
+ const main = await serverPlanView(goReady, "main");
505
+ const ui = mount({
506
+ "GET /api/progress": () => idleProgress(),
507
+ "GET /api/plan": (call) => [200, call.url.endsWith("channel=main") ? main : release],
508
+ });
509
+ await ui.app.start();
510
+ await flush();
511
+ const radios = () => all(ui.view, "input").filter((input) => input.getAttribute("type") === "radio");
512
+ assert.deepEqual(radios().map((radio) => [radio.getAttribute("value"), radio.checked === true]), [["release", true], ["main", false]]);
513
+ assert.ok(ui.view.textContent.includes("Latest release"));
514
+ assert.ok(ui.view.textContent.includes("Latest main"));
515
+ const choose = radios()[1];
516
+ choose.checked = true;
517
+ dispatch(choose, "change");
518
+ await flush();
519
+ const plans = ui.server.calls.filter((call) => call.url.startsWith("/api/plan")).map((call) => call.url);
520
+ assert.deepEqual(plans, ["/api/plan?channel=release", "/api/plan?channel=main"]);
521
+ assert.deepEqual(radios().map((radio) => [radio.getAttribute("value"), radio.checked === true]), [["release", false], ["main", true]]);
522
+ assert.ok(ui.view.textContent.includes(plain(main.actions.find((action: { id: string }) => action.id === "build-gentle-ai-main").description)));
523
+ });
524
+
525
+ test("an already set-up computer points to gentle-shell upgrade, and to its main channel when main is chosen", () => {
526
+ for (const [channel, expected] of [["release", "Run gentle-shell in a terminal. To upgrade, run gentle-shell upgrade."],
527
+ ["main", "Run gentle-shell in a terminal. To follow main, run gentle-shell upgrade --channel main."]]) {
528
+ const document = new FakeDocument();
529
+ const model = wizard.planModel({ planId: "p", ready: true, channel, actions: [{ id: "verify-readiness", description: "Verify." }], blockers: [] });
530
+ const view = wizard.renderPlan(document, model, { install: () => {}, reload: () => {}, close: () => {} });
531
+ assert.ok(view.textContent.includes(expected), channel);
532
+ }
533
+ assert.equal(guidance.blocked["existing-stack"], "Pi or Gentle Shell is already installed globally. Nothing was changed; use `gentle-shell upgrade` instead.");
534
+ assert.ok(guidance.blocked["unsupported-plan"].includes("Use `gentle-shell upgrade` or follow the README."));
535
+ });
536
+
537
+ test("expectedSteps mirrors the shell-only installation and the update of an existing Gentle Shell", () => {
538
+ assert.deepEqual(wizard.expectedSteps(["install-shell", "setup-shell", "verify-readiness"]),
539
+ ["check-npm", "check-global-bin", "check-existing-shell", "install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]);
540
+ assert.deepEqual(wizard.expectedSteps(["install-pi", "update-shell-release", "setup-shell", "verify-readiness"]),
541
+ ["check-npm", "check-global-bin", "check-installed-shell", "install-pi", "update-shell", "verify-updated-shell", "verify-gentle-ai", "shell-setup"]);
542
+ assert.deepEqual(wizard.expectedSteps(["update-shell-main", "setup-shell", "verify-readiness"]),
543
+ ["check-npm", "check-global-bin", "check-installed-shell", "update-shell", "verify-updated-shell", "shell-setup"]);
544
+ });
545
+
546
+ test("expectedSteps checks, updates and verifies an older Pi before any Gentle Shell step", () => {
547
+ assert.deepEqual(wizard.expectedSteps(["update-pi", "install-shell", "setup-shell", "verify-readiness"]),
548
+ ["check-npm", "check-global-bin", "check-existing-shell", "check-installed-pi", "update-pi", "verify-updated-pi", "install-global",
549
+ "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]);
550
+ assert.deepEqual(wizard.expectedSteps(["update-pi", "update-shell-release", "setup-shell", "verify-readiness"]),
551
+ ["check-npm", "check-global-bin", "check-installed-shell", "check-installed-pi", "update-pi", "verify-updated-pi", "update-shell",
552
+ "verify-updated-shell", "verify-gentle-ai", "shell-setup"]);
553
+ assert.deepEqual(wizard.expectedSteps(["update-pi", "verify-readiness"]),
554
+ ["check-npm", "check-global-bin", "check-installed-pi", "update-pi", "verify-updated-pi"]);
555
+ // The installer's Pi added on its own, next to an older one that neither pnpm nor npm owns.
556
+ assert.deepEqual(wizard.expectedSteps(["install-pi", "verify-readiness"]),
557
+ ["check-npm", "check-global-bin", "check-existing-pi", "install-pi", "verify-installed-pi"]);
558
+ for (const id of ["check-installed-pi", "update-pi", "verify-updated-pi", "check-existing-pi", "verify-installed-pi"]) {
559
+ assert.notEqual(wizard.stepLabel(id), id);
560
+ }
561
+ });
562
+
563
+ test("a plan that only updates an older Pi is reviewed as a Pi update, with an Update Pi button", () => {
564
+ const document = new FakeDocument();
565
+ const model = wizard.planModel({ planId: "p", ready: false, channel: "release", blockers: [],
566
+ actions: [{ id: "update-pi", description: "Update Pi 0.87.1 to 1.0.0 with npm." }, { id: "verify-readiness", description: "Verify." }] });
567
+ assert.equal(model.kind, "update-pi");
568
+ const view = wizard.renderPlan(document, model, { install: () => {}, reload: () => {}, close: () => {} });
569
+ assert.ok(headings(view)[0].textContent.includes("Update Pi"));
570
+ assert.ok(button(view, "Update Pi"));
571
+ assert.equal(button(view, "Install Gentle Shell"), undefined);
572
+ const alongside = wizard.planModel({ planId: "p", blockers: [], actions: [{ id: "install-pi", description: "Install Pi." },
573
+ { id: "verify-readiness", description: "Verify." }] });
574
+ assert.equal(alongside.kind, "install-pi");
575
+ const installView = wizard.renderPlan(document, alongside, { install: () => {}, reload: () => {}, close: () => {} });
576
+ assert.ok(headings(installView)[0].textContent.includes("Install Pi"));
577
+ assert.ok(button(installView, "Install Pi"));
578
+ // The same plan serves a missing Pi and an older one neither pnpm nor npm owns.
579
+ assert.ok(installView.textContent.includes("Pi is missing or older than it needs"));
580
+ assert.equal(installView.textContent.includes("needs a newer Pi"), false);
581
+ // Combined with a Gentle Shell installation or update, the plan keeps its usual kind.
582
+ const install = wizard.planModel({ planId: "p", blockers: [], actions: [{ id: "update-pi" }, { id: "install-shell" }, { id: "setup-shell" },
583
+ { id: "verify-readiness" }] });
584
+ assert.equal(install.kind, "install");
585
+ const update = wizard.planModel({ planId: "p", blockers: [], actions: [{ id: "update-pi" }, { id: "update-shell-release" },
586
+ { id: "setup-shell" }, { id: "verify-readiness" }] });
587
+ assert.equal(update.kind, "update");
588
+ });
589
+
590
+ test("an update plan is reviewed as an update, with an Update button", () => {
591
+ const document = new FakeDocument();
592
+ const model = wizard.planModel({ planId: "p", ready: false, channel: "release", blockers: [],
593
+ actions: [{ id: "update-shell-release", description: "Update." }, { id: "setup-shell", description: "Setup." }, { id: "verify-readiness", description: "Verify." }] });
594
+ assert.equal(model.kind, "update");
595
+ const view = wizard.renderPlan(document, model, { install: () => {}, reload: () => {}, close: () => {} });
596
+ assert.ok(headings(view)[0].textContent.includes("Update Gentle Shell"));
597
+ assert.ok(button(view, "Update Gentle Shell"));
598
+ assert.equal(button(view, "Install Gentle Shell"), undefined);
599
+ });
600
+
601
+ test("a blocked main plan still lets the user switch back to release", async () => {
602
+ const blocked = await serverPlanView({ node: { available: null } }, "main");
603
+ assert.ok(blocked.blockers.some((blocker: { code: string }) => blocker.code === "unknown-tool"));
604
+ const ui = mount({ "GET /api/progress": () => idleProgress(), "GET /api/plan": () => [200, blocked] });
605
+ await ui.app.start();
606
+ await flush();
607
+ assert.equal(all(ui.view, "input").filter((input) => input.getAttribute("type") === "radio").length, 2);
608
+ });
609
+
610
+ test("progressModel shows done, failed, in-progress and pending steps without relying on color", () => {
611
+ const steps = ["check-npm", "check-global-bin", "install-global"];
612
+ const running = wizard.progressModel(steps, [{ seq: 1, step: "check-npm", status: "done", reason: null }], { running: true, outcome: null });
613
+ assert.deepEqual(running.items.map((item: { status: string }) => item.status), ["done", "running", "pending"]);
614
+ assert.deepEqual([running.done, running.total], [1, 3]);
615
+ for (const item of running.items) assert.ok(item.statusText.length > 0 && item.label.length > 0);
616
+
617
+ const failed = wizard.progressModel(steps, [{ seq: 1, step: "check-npm", status: "done", reason: null },
618
+ { seq: 2, step: "check-global-bin", status: "done", reason: null }, { seq: 3, step: "install-global", status: "failed", reason: null }],
619
+ { running: false, outcome: { outcome: "failed", failedStep: "install-global", completed: ["check-npm", "check-global-bin"], guidance: "g" } });
620
+ assert.deepEqual(failed.items.map((item: { status: string }) => item.status), ["done", "done", "failed"]);
621
+ assert.equal(failed.items[2].statusText, "Failed");
622
+
623
+ const blocked = wizard.progressModel(steps, [{ seq: 1, step: "gate", status: "blocked", reason: "existing-stack" }],
624
+ { running: false, outcome: { outcome: "blocked", reason: "existing-stack", completed: [], guidance: "g" } });
625
+ assert.deepEqual(blocked.items.map((item: { status: string }) => item.status), ["blocked", "skipped", "skipped", "skipped"]);
626
+ // An unexpected logged step is still shown, and the total never shrinks below what ran.
627
+ const extra = wizard.progressModel(["check-npm"], [{ seq: 1, step: "check-npm", status: "done", reason: null },
628
+ { seq: 2, step: "surprise", status: "done", reason: null }], { running: true, outcome: null });
629
+ assert.deepEqual(extra.items.map((item: { id: string }) => item.id), ["check-npm", "surprise"]);
630
+ assert.ok(extra.total >= 2);
631
+ });
632
+
633
+ test("outcomeModel covers every runner outcome, blocked reason and failed step with the server guidance", () => {
634
+ const ready = wizard.outcomeModel({ outcome: "ready", completed: ["install-global"], guidance: guidance.outcomes.ready });
635
+ assert.equal(ready.tone, "success");
636
+ assert.equal(ready.command, "gentle-shell");
637
+ assert.equal(ready.guidance, guidance.outcomes.ready);
638
+ const terminal = wizard.outcomeModel({ outcome: "terminal-action-required", action: "open-new-terminal", npmPrefix: "configured",
639
+ completed: ["persist-path"], guidance: guidance.outcomes["terminal-action-required"] });
640
+ assert.equal(terminal.tone, "success");
641
+ assert.equal(terminal.command, "gentle-shell");
642
+ assert.match(terminal.title, /new terminal/i);
643
+ assert.ok(terminal.next.some((line: string) => /open a new terminal/i.test(line)));
644
+ assert.ok(terminal.notes.some((line: string) => /npm/i.test(line)));
645
+ for (const reason of blockedReasons) {
646
+ const model = wizard.outcomeModel({ outcome: "blocked", reason, completed: [], guidance: guidance.blocked[reason] });
647
+ assert.equal(model.tone, "warning", reason);
648
+ assert.equal(model.guidance, guidance.blocked[reason], reason);
649
+ assert.equal(model.command, null);
650
+ assert.match(model.lead, /nothing was installed/i);
651
+ }
652
+ for (const step of failedSteps) {
653
+ const model = wizard.outcomeModel({ outcome: "failed", failedStep: step, completed: ["check-npm"], guidance: guidance.failed[step] });
654
+ assert.equal(model.tone, "error", step);
655
+ assert.equal(model.guidance, guidance.failed[step], step);
656
+ assert.equal(model.failedStep.label, wizard.stepLabel(step));
657
+ assert.deepEqual(model.completed.map((item: { id: string }) => item.id), ["check-npm"]);
658
+ }
659
+ const unknown = wizard.outcomeModel({ outcome: "failed", failedStep: null, completed: [], guidance: guidance.fallback });
660
+ assert.equal(unknown.guidance, guidance.fallback);
661
+ assert.equal(unknown.failedStep, null);
662
+ });
663
+
664
+ test("a failed setup shows its last error as labelled plain text", () => {
665
+ const document = new FakeDocument();
666
+ const hostile = "Error: `rm -rf` <img src=x onerror=alert(1)> GitHub API returned HTTP 403";
667
+ const model = wizard.outcomeModel({ outcome: "failed", failedStep: "shell-setup", completed: [], guidance: guidance.setupRateLimit, detail: hostile });
668
+ assert.equal(model.detail, hostile);
669
+ assert.equal(model.guidance, guidance.setupRateLimit);
670
+ const node = wizard.renderOutcome(document, model, { close() {} });
671
+ const section = all(node, "section").find((element) => element.textContent.startsWith("Last error from gentle-shell setup"));
672
+ assert.ok(section, "labelled detail section");
673
+ const label = all(section, "h2")[0];
674
+ assert.equal(label.textContent, "Last error from gentle-shell setup");
675
+ assert.equal(section.getAttribute("aria-labelledby"), label.getAttribute("id"));
676
+ // Backticks stay literal: the detail is one text node, never `rich` code spans or markup.
677
+ const codes = all(section, "code");
678
+ assert.equal(codes.length, 1);
679
+ assert.equal(codes[0].textContent, hostile);
680
+ assert.equal(all(node, "img").length, 0);
681
+ // pnpm setup's detail is labelled with its own fixed command name.
682
+ const pnpmDetail = "[ERR_PNPM_UNKNOWN_SHELL] Could not infer shell type.";
683
+ const persist = wizard.outcomeModel({ outcome: "failed", failedStep: "persist-path", completed: [], guidance: guidance.persistPathShell, detail: pnpmDetail });
684
+ assert.equal(persist.detail, pnpmDetail);
685
+ assert.equal(persist.detailCommand, "pnpm setup");
686
+ // The Go download's folder conflict is labelled with that step.
687
+ const goDetail = "Conflicting Go destination: ~/.pi/gentle-ai/tools/go/1.25.14";
688
+ const go = wizard.outcomeModel({ outcome: "failed", failedStep: "acquire-go", completed: [], guidance: guidance.goDestinationConflict, detail: goDetail });
689
+ assert.equal(go.detail, goDetail);
690
+ assert.equal(go.detailCommand, "the Go download");
691
+ const persistNode = wizard.renderOutcome(document, persist, { close() {} });
692
+ const persistSection = all(persistNode, "section").find((element) => element.textContent.startsWith("Last error from pnpm setup"));
693
+ assert.ok(persistSection, "pnpm setup detail section");
694
+ assert.equal(all(persistSection, "code")[0].textContent, pnpmDetail);
695
+ assert.doesNotMatch(persistNode.textContent, /Last error from gentle-shell setup/);
696
+ // The install, main-channel and update steps label their detail with what failed.
697
+ for (const [step, label] of [["install-global", "pnpm add -g"], ["install-shell-main", "the Gentle Shell main install"],
698
+ ["build-gentle-ai-main", "the Gentle AI main build"], ["update-shell", "the Gentle Shell update"]]) {
699
+ const failed = wizard.outcomeModel({ outcome: "failed", failedStep: step, completed: [], guidance: "g", detail: hostile });
700
+ assert.deepEqual([failed.detail, failed.detailCommand], [hostile, label], step);
701
+ const rendered = wizard.renderOutcome(document, failed, { close() {} });
702
+ const labelled = all(rendered, "section").find((element) => element.textContent.startsWith(`Last error from ${label}`));
703
+ assert.ok(labelled, `${step} detail section`);
704
+ assert.equal(all(labelled, "code")[0].textContent, hostile);
705
+ }
706
+ // The detail is shown only for a failed setup step with a string value, bounded to 300 characters.
707
+ const long = wizard.outcomeModel({ outcome: "failed", failedStep: "shell-setup", completed: [], guidance: "g", detail: "y".repeat(400) });
708
+ assert.equal(long.detail.length, 300);
709
+ for (const outcome of [
710
+ { outcome: "failed", failedStep: "record-channel", completed: [], guidance: "g", detail: hostile },
711
+ { outcome: "failed", failedStep: "shell-setup", completed: [], guidance: "g", detail: 42 },
712
+ { outcome: "failed", failedStep: "shell-setup", completed: [], guidance: "g", detail: "" },
713
+ { outcome: "ready", completed: [], guidance: "g", detail: hostile },
714
+ ]) {
715
+ const other = wizard.outcomeModel(outcome);
716
+ assert.equal(other.detail, null, JSON.stringify(outcome));
717
+ assert.doesNotMatch(wizard.renderOutcome(document, other, { close() {} }).textContent, /Last error from gentle-shell setup/);
718
+ }
719
+ });
720
+
721
+ test("renderOutcome renders every outcome with text nodes only, so server strings stay inert", () => {
722
+ const document = new FakeDocument();
723
+ const hostile = "<img src=x onerror=alert(1)>";
724
+ const outcomes = [
725
+ { outcome: "ready", completed: [], guidance: hostile },
726
+ { outcome: "terminal-action-required", action: "open-new-terminal", completed: [], guidance: hostile },
727
+ ...blockedReasons.map((reason: string) => ({ outcome: "blocked", reason, completed: [], guidance: guidance.blocked[reason] })),
728
+ ...failedSteps.map((step: string) => ({ outcome: "failed", failedStep: step, completed: [hostile], guidance: guidance.failed[step] })),
729
+ ];
730
+ for (const outcome of outcomes) {
731
+ const node = wizard.renderOutcome(document, wizard.outcomeModel(outcome), { close() {} });
732
+ assert.equal(headings(node).length, 1);
733
+ assert.ok(node.textContent.includes(plain(outcome.guidance)), outcome.outcome);
734
+ assert.ok(all(node, "img").length === 0);
735
+ assert.ok(button(node, "Close installer"), "close button");
736
+ }
737
+ const plan = wizard.renderPlan(document, wizard.planModel({ planId: "p", ready: false,
738
+ actions: [{ id: "install-pi", description: hostile }], blockers: [],
739
+ profileChange: { changesProfile: true, binDir: hostile, description: hostile }, persistence: { tools: [], pnpmHome: null, description: hostile } }),
740
+ { install() {}, reload() {}, close() {} });
741
+ assert.ok(plan.textContent.includes(hostile));
742
+ assert.equal(all(plan, "img").length, 0);
743
+ });
744
+
745
+ // ---------------------------------------------------------------------------
746
+ // Request and polling policies
747
+ // ---------------------------------------------------------------------------
748
+
749
+ test("installBody requires explicit consent and sends exactly { planId, consent: true }", () => {
750
+ assert.equal(wizard.installBody("plan-1", false), null);
751
+ assert.equal(wizard.installBody("plan-1", undefined), null);
752
+ assert.equal(wizard.installBody("", true), null);
753
+ assert.deepEqual(wizard.installBody("plan-1", true), { planId: "plan-1", consent: true });
754
+ assert.deepEqual(Object.keys(wizard.installBody("plan-1", true)).sort(), ["consent", "planId"]);
755
+ });
756
+
757
+ test("interpretInstall maps every host answer to one next step", () => {
758
+ assert.equal(wizard.interpretInstall(202, { started: true }).next, "progress");
759
+ assert.equal(wizard.interpretInstall(409, { error: "plan-changed" }).next, "reload-plan");
760
+ assert.match(wizard.interpretInstall(409, { error: "plan-changed" }).message, /changed/i);
761
+ assert.equal(wizard.interpretInstall(409, { error: "install-running" }).next, "progress");
762
+ assert.equal(wizard.interpretInstall(409, { error: "already-completed" }).next, "outcome");
763
+ assert.equal(wizard.interpretInstall(401, { error: "unauthorized" }).next, "session");
764
+ assert.equal(wizard.interpretInstall(403, { error: "forbidden" }).next, "session");
765
+ for (const status of [400, 413, 415, 500, 502]) assert.equal(wizard.interpretInstall(status, { error: "x" }).next, "error", String(status));
766
+ });
767
+
768
+ test("nextPoll backs off while quiet, resets on news, and stops on outcome, repeated errors or a stalled host", () => {
769
+ const policy = wizard.pollPolicy;
770
+ let state = wizard.initialPoll();
771
+ assert.equal(state.delay, policy.initialMs);
772
+ const quiet = [];
773
+ for (let i = 0; i < 7; i += 1) {
774
+ state = wizard.nextPoll(state, { type: "entries", count: 0, running: true, outcome: null });
775
+ quiet.push(state.delay);
776
+ }
777
+ assert.deepEqual(quiet, [750, 1125, 1688, 2532, 3798, 4000, 4000], "quiet polls back off by 1.5x up to 4 s");
778
+ for (let i = 0; i < 20; i += 1) state = wizard.nextPoll(state, { type: "entries", count: 0, running: true, outcome: null });
779
+ assert.equal(state.delay, policy.maxMs);
780
+ assert.equal(state.stop, false, "a long install keeps polling at the capped delay");
781
+ state = wizard.nextPoll(state, { type: "entries", count: 2, running: true, outcome: null });
782
+ assert.equal(state.delay, policy.initialMs);
783
+
784
+ const done = wizard.nextPoll(state, { type: "entries", count: 1, running: false, outcome: { outcome: "ready" } });
785
+ assert.deepEqual([done.stop, done.reason], [true, "outcome"]);
786
+
787
+ let failing = wizard.initialPoll();
788
+ const delays = [];
789
+ for (let i = 0; i < policy.maxFailures; i += 1) {
790
+ failing = wizard.nextPoll(failing, { type: "error" });
791
+ delays.push(failing.delay);
792
+ }
793
+ assert.deepEqual([failing.stop, failing.reason], [true, "errors"]);
794
+ assert.deepEqual(delays, [1000, 2000, 4000, 8000, 8000], "errors double the delay up to 8 s");
795
+ let recovered = wizard.nextPoll(wizard.nextPoll(wizard.initialPoll(), { type: "error" }), { type: "entries", count: 0, running: true, outcome: null });
796
+ assert.equal(recovered.failures, 0);
797
+
798
+ let stalled = wizard.initialPoll();
799
+ for (let i = 0; i < policy.maxIdle; i += 1) stalled = wizard.nextPoll(stalled, { type: "entries", count: 0, running: false, outcome: null });
800
+ assert.deepEqual([stalled.stop, stalled.reason], [true, "stalled"]);
801
+ recovered = wizard.nextPoll(wizard.initialPoll(), { type: "entries", count: 0, running: true, outcome: null });
802
+ assert.equal(recovered.idle, 0);
803
+ });
804
+
805
+ // ---------------------------------------------------------------------------
806
+ // Controller with fake DOM, fetch and timers
807
+ // ---------------------------------------------------------------------------
808
+
809
+ test("happy path: plan, consent required before the request, progress polling, outcome, close", async () => {
810
+ const view = await serverPlanView();
811
+ const steps = wizard.expectedSteps(view.actions.map((action: { id: string }) => action.id));
812
+ let polls = 0;
813
+ const ui = mount({
814
+ "GET /api/progress": (call) => {
815
+ polls += 1;
816
+ if (call.url.endsWith("after=0") && polls === 1) return idleProgress();
817
+ if (polls === 2) return [200, { entries: [{ seq: 1, step: steps[0], status: "done", reason: null }], running: true, outcome: null }];
818
+ if (polls === 3) return [200, { entries: [], running: true, outcome: null }];
819
+ return [200, { entries: steps.slice(1).map((step: string, index: number) => ({ seq: index + 2, step, status: "done", reason: null })),
820
+ running: false, outcome: { outcome: "terminal-action-required", action: "open-new-terminal", completed: steps,
821
+ guidance: guidance.outcomes["terminal-action-required"] } }];
822
+ },
823
+ "GET /api/plan": () => [200, view],
824
+ "POST /api/install": () => [202, { started: true }],
825
+ "POST /api/shutdown": () => [200, { closing: true }],
826
+ });
827
+ await ui.app.start();
828
+ await flush();
829
+ assert.equal(headings(ui.view).length, 1);
830
+ assert.match(headings(ui.view)[0].textContent, /review/i);
831
+ assert.equal(ui.document.activeElement, headings(ui.view)[0], "focus moves to the new step heading");
832
+ assert.ok(ui.view.textContent.includes(plain(view.profileChange.description)));
833
+ assert.ok(ui.view.textContent.includes(plain(view.persistence.description)));
834
+ for (const call of ui.server.calls) assert.equal(call.headers["X-Gentle-Install"], "1");
835
+
836
+ const install = button(ui.view, "Install") as FakeElement;
837
+ const consent = all(ui.view, "input").find((input) => input.getAttribute("type") === "checkbox") as FakeElement;
838
+ assert.ok(consent && install);
839
+ assert.ok(all(ui.view, "label").some((label) => label.getAttribute("for") === consent.getAttribute("id")), "consent is labelled");
840
+ dispatch(install, "click");
841
+ await flush();
842
+ assert.equal(ui.server.calls.filter((call) => call.method === "POST").length, 0, "no install request without consent");
843
+ assert.ok(ui.alert.textContent.length > 0, "assertive error explains why");
844
+ assert.equal(ui.document.activeElement, consent);
845
+
846
+ consent.checked = true;
847
+ dispatch(consent, "change");
848
+ dispatch(install, "click");
849
+ await flush();
850
+ const posted = ui.server.calls.filter((call) => call.method === "POST");
851
+ assert.equal(posted.length, 1);
852
+ assert.deepEqual(posted[0].body, { planId: view.planId, consent: true });
853
+ assert.equal(posted[0].headers["Content-Type"], "application/json");
854
+ assert.equal(ui.alert.textContent, "", "the consent error clears");
855
+ assert.match(headings(ui.view)[0].textContent, /installing/i);
856
+ assert.equal(ui.document.activeElement, headings(ui.view)[0]);
857
+ assert.equal(all(ui.view, "progress").length, 1);
858
+
859
+ const first = await ui.timers.runNext();
860
+ assert.equal(first.ms, 500, "the first poll is scheduled at the initial delay");
861
+ assert.ok(ui.status.textContent.length > 0, "polite progress announcement");
862
+ assert.match(ui.server.calls.at(-1)?.url ?? "", /after=0$/);
863
+ const second = await ui.timers.runNext();
864
+ assert.match(ui.server.calls.at(-1)?.url ?? "", /after=1$/);
865
+ assert.equal(second.ms, 500, "news keeps the initial delay");
866
+ const third = await ui.timers.runNext();
867
+ assert.equal(third.ms, 750, "a quiet poll backs off by 1.5x");
868
+ assert.equal(ui.timers.pending().length, 0, "polling stops on the final outcome");
869
+ assert.match(headings(ui.view)[0].textContent, /new terminal/i);
870
+ assert.ok(ui.view.textContent.includes("gentle-shell"));
871
+ assert.equal(ui.document.activeElement, headings(ui.view)[0]);
872
+
873
+ dispatch(button(ui.view, "Close installer") as FakeElement, "click");
874
+ await flush();
875
+ assert.deepEqual(ui.server.calls.at(-1)?.body, {});
876
+ assert.match(headings(ui.view)[0].textContent, /closed/i);
877
+ assert.match(ui.status.textContent, /installer closed/i, "the live region announces the closed state");
878
+ assert.doesNotMatch(ui.status.textContent, /new terminal|ready/i);
879
+ });
880
+
881
+ test("409 plan-changed reloads the plan and requires consent again", async () => {
882
+ const first = await serverPlanView({ globalBin: { ...cleanInventory.globalBin, onPath: true } });
883
+ const second = await serverPlanView();
884
+ let plans = 0;
885
+ let installs = 0;
886
+ const ui = mount({
887
+ "GET /api/progress": idleProgress,
888
+ "GET /api/plan": () => [200, plans++ === 0 ? first : second],
889
+ "POST /api/install": () => (installs++ === 0 ? [409, { error: "plan-changed" }] : [202, { started: true }]),
890
+ });
891
+ await ui.app.start();
892
+ await flush();
893
+ let consent = all(ui.view, "input").find((input) => input.getAttribute("type") === "checkbox") as FakeElement;
894
+ consent.checked = true;
895
+ dispatch(button(ui.view, "Install") as FakeElement, "click");
896
+ await flush();
897
+ assert.equal(plans, 2, "the plan was reloaded");
898
+ assert.match(ui.alert.textContent, /changed/i);
899
+ assert.match(headings(ui.view)[0].textContent, /review/i);
900
+ consent = all(ui.view, "input").find((input) => input.getAttribute("type") === "checkbox") as FakeElement;
901
+ assert.equal(consent.checked, false, "consent must be given again");
902
+ assert.ok(ui.view.textContent.includes(plain(second.profileChange.description)));
903
+ dispatch(button(ui.view, "Install") as FakeElement, "click");
904
+ await flush();
905
+ assert.equal(installs, 1, "no request until consent is given again");
906
+ consent.checked = true;
907
+ dispatch(button(ui.view, "Install") as FakeElement, "click");
908
+ await flush();
909
+ assert.equal(installs, 2);
910
+ assert.deepEqual(ui.server.calls.filter((call) => call.method === "POST").at(-1)?.body, { planId: second.planId, consent: true });
911
+ });
912
+
913
+ test("start resumes a running installation or shows a finished outcome instead of a new plan", async () => {
914
+ const running = mount({ "GET /api/progress": () => [200, { entries: [{ seq: 1, step: "check-npm", status: "done", reason: null }], running: true,
915
+ outcome: null }] });
916
+ await running.app.start();
917
+ await flush();
918
+ assert.match(headings(running.view)[0].textContent, /installing/i);
919
+ assert.ok(running.timers.pending().length === 1);
920
+ assert.ok(!running.server.calls.some((call) => call.url === "/api/plan"));
921
+
922
+ const finished = mount({ "GET /api/progress": () => [200, { entries: [], running: false, outcome: { outcome: "failed", failedStep: "install-global",
923
+ completed: [], guidance: guidance.failed["install-global"] } }] });
924
+ await finished.app.start();
925
+ await flush();
926
+ assert.match(headings(finished.view)[0].textContent, /failed/i);
927
+ assert.ok(finished.view.textContent.includes(plain(guidance.failed["install-global"])));
928
+ assert.equal(finished.timers.pending().length, 0);
929
+ // The stage bar says in text, not only color, that installation stopped.
930
+ const stagesRoot = finished.document.roots.get("stages") as FakeElement;
931
+ assert.match(stagesRoot.textContent, /Install \(stopped\)/);
932
+ assert.equal(all(stagesRoot, "li").filter((item) => item.getAttribute("aria-current") === "step").length, 1);
933
+ assert.doesNotMatch((running.document.roots.get("stages") as FakeElement).textContent, /stopped/);
934
+ });
935
+
936
+ test("network errors show an assertive message with a working retry; repeated poll errors stop polling", async () => {
937
+ let failPlan = true;
938
+ const view = await serverPlanView();
939
+ const ui = mount({
940
+ "GET /api/progress": idleProgress,
941
+ "GET /api/plan": () => (failPlan ? new TypeError("Failed to fetch") : [200, view]),
942
+ });
943
+ await ui.app.start();
944
+ await flush();
945
+ assert.ok(ui.alert.textContent.length > 0);
946
+ const retry = button(ui.alert, "Try again") ?? button(ui.view, "Try again");
947
+ assert.ok(retry, "retry control");
948
+ failPlan = false;
949
+ dispatch(retry as FakeElement, "click");
950
+ await flush();
951
+ assert.match(headings(ui.view)[0].textContent, /review/i);
952
+ assert.equal(ui.alert.textContent, "");
953
+
954
+ let progressCalls = 0;
955
+ const stalled = mount({ "GET /api/progress": () => (progressCalls++ === 0 ? [200, { entries: [], running: true, outcome: null }]
956
+ : new TypeError("Failed to fetch")) });
957
+ await stalled.app.start();
958
+ await flush();
959
+ for (let i = 0; i < wizard.pollPolicy.maxFailures; i += 1) await stalled.timers.runNext();
960
+ assert.equal(stalled.timers.pending().length, 0, "bounded: polling stops after repeated errors");
961
+ assert.ok(stalled.alert.textContent.length > 0);
962
+ assert.ok(button(stalled.alert, "Try again") ?? button(stalled.view, "Try again"));
963
+ });
964
+
965
+ test("an expired session explains how to restart instead of retrying forever", async () => {
966
+ const ui = mount({ "GET /api/progress": () => [401, { error: "unauthorized" }] });
967
+ await ui.app.start();
968
+ await flush();
969
+ assert.match(ui.alert.textContent + ui.view.textContent, /run the installer again/i);
970
+ assert.equal(ui.timers.pending().length, 0);
971
+ });
972
+
973
+ // ---------------------------------------------------------------------------
974
+ // Preview script: the real host with fake scenarios, never real probes or installs
975
+ // ---------------------------------------------------------------------------
976
+
977
+ test("every preview scenario runs end to end on the real host with fake plans and steps", async () => {
978
+ assert.deepEqual([...scenarioNames].sort(), ["blocked", "failed", "plan-changed", "preflight", "ready", "recovery", "terminal"]);
979
+ const expected: Record<string, string | null> = { ready: "ready", terminal: "terminal-action-required", blocked: "blocked", failed: "failed",
980
+ "plan-changed": "ready", preflight: null, recovery: "terminal-action-required" };
981
+ for (const scenario of scenarioNames) {
982
+ const { host, url } = await startPreview({ scenario, stepMs: 0, log: () => {} });
983
+ const port = Number(new URL(url).port);
984
+ try {
985
+ const cookie = await login(port, url);
986
+ let { body: plan } = await getJson(port, "/api/plan", cookie);
987
+ // The recovery plan only completes setup; its fake steps match the runner's recovery order.
988
+ if (scenario === "recovery") assert.equal(wizard.planModel(plan).kind, "recovery");
989
+ if (expected[scenario] === null) {
990
+ assert.equal(plan.blockers.length > 0, true, scenario);
991
+ continue;
992
+ }
993
+ let response = await post(port, "/api/install", cookie, { planId: plan.planId, consent: true });
994
+ if (scenario === "plan-changed") {
995
+ assert.equal(response.status, 409);
996
+ assert.equal(JSON.parse(response.body).error, "plan-changed");
997
+ ({ body: plan } = await getJson(port, "/api/plan", cookie));
998
+ response = await post(port, "/api/install", cookie, { planId: plan.planId, consent: true });
999
+ }
1000
+ assert.equal(response.status, 202, scenario);
1001
+ let progress = { outcome: null as null | { outcome: string } };
1002
+ for (let i = 0; i < 200 && progress.outcome === null; i += 1) {
1003
+ ({ body: progress } = await getJson(port, "/api/progress?after=0", cookie));
1004
+ if (progress.outcome === null) await new Promise((done) => setTimeout(done, 5));
1005
+ }
1006
+ assert.equal(progress.outcome?.outcome, expected[scenario], scenario);
1007
+ } finally {
1008
+ await host.close("test");
1009
+ }
1010
+ }
1011
+ });
1012
+
1013
+ test("a network failure on the install request checks progress once before offering a retry", async () => {
1014
+ const view = await serverPlanView();
1015
+ const failingPost = () => new TypeError("Failed to fetch");
1016
+ const answers = (later: () => Reply) => {
1017
+ let calls = 0;
1018
+ return () => (calls++ === 0 ? idleProgress() : later());
1019
+ };
1020
+
1021
+ // The request reached the host, which is now installing: show progress, never re-send the install.
1022
+ const started = mount({ "GET /api/progress": answers(() => [200, { entries: [{ seq: 1, step: "check-npm", status: "done", reason: null }],
1023
+ running: true, outcome: null }]), "GET /api/plan": () => [200, view], "POST /api/install": failingPost });
1024
+ await started.app.start();
1025
+ await flush();
1026
+ await consentAndInstall(started);
1027
+ assert.match(headings(started.view)[0].textContent, /installing/i);
1028
+ assert.equal(started.alert.textContent, "");
1029
+ assert.deepEqual([posts(started, "/api/install"), progressChecks(started)], [1, 2]);
1030
+ assert.equal(started.timers.pending().length, 1, "polling continues");
1031
+
1032
+ // The installation already finished: show its outcome.
1033
+ const finished = mount({ "GET /api/progress": answers(() => outcomeProgress({ outcome: "failed", failedStep: "install-global", completed: [],
1034
+ guidance: guidance.failed["install-global"] })), "GET /api/plan": () => [200, view], "POST /api/install": failingPost });
1035
+ await finished.app.start();
1036
+ await flush();
1037
+ await consentAndInstall(finished);
1038
+ assert.match(headings(finished.view)[0].textContent, /failed/i);
1039
+ assert.equal(finished.alert.textContent, "");
1040
+
1041
+ // Nothing started, or the host does not answer either: stay on the plan with an error and a usable Install button.
1042
+ for (const later of [idleProgress, () => new TypeError("Failed to fetch") as Reply]) {
1043
+ const idle = mount({ "GET /api/progress": answers(later), "GET /api/plan": () => [200, view], "POST /api/install": failingPost });
1044
+ await idle.app.start();
1045
+ await flush();
1046
+ await consentAndInstall(idle);
1047
+ assert.match(headings(idle.view)[0].textContent, /review/i);
1048
+ assert.match(idle.alert.textContent, /did not answer/i);
1049
+ assert.equal((button(idle.view, "Install") as FakeElement).disabled, false);
1050
+ assert.deepEqual([posts(idle, "/api/install"), progressChecks(idle)], [1, 2], "exactly one progress check");
1051
+ assert.equal(idle.timers.pending().length, 0);
1052
+ }
1053
+ });
1054
+
1055
+ test("install answers already-completed and install-running resume the host's current state", async () => {
1056
+ const view = await serverPlanView();
1057
+ let checks = 0;
1058
+ const completed = mount({
1059
+ "GET /api/progress": () => (checks++ === 0 ? idleProgress() : outcomeProgress(readyOutcome)),
1060
+ "GET /api/plan": () => [200, view],
1061
+ "POST /api/install": () => [409, { error: "already-completed" }],
1062
+ });
1063
+ await completed.app.start();
1064
+ await flush();
1065
+ await consentAndInstall(completed);
1066
+ assert.match(headings(completed.view)[0].textContent, /ready/i);
1067
+ assert.equal(completed.alert.textContent, "");
1068
+ assert.equal(completed.timers.pending().length, 0);
1069
+
1070
+ const running = mount({
1071
+ "GET /api/progress": idleProgress,
1072
+ "GET /api/plan": () => [200, view],
1073
+ "POST /api/install": () => [409, { error: "install-running" }],
1074
+ });
1075
+ await running.app.start();
1076
+ await flush();
1077
+ await consentAndInstall(running);
1078
+ assert.match(headings(running.view)[0].textContent, /installing/i);
1079
+ assert.equal(running.alert.textContent, "");
1080
+ assert.equal(running.timers.pending().length, 1, "polls the running installation");
1081
+ assert.equal(posts(running, "/api/install"), 1);
1082
+ });
1083
+
1084
+ test("closing while the host still installs keeps the outcome screen and explains why", async () => {
1085
+ const ui = mount({
1086
+ "GET /api/progress": () => outcomeProgress({ outcome: "failed", failedStep: "shell-setup", completed: [], guidance: guidance.failed["shell-setup"] }),
1087
+ "POST /api/shutdown": () => [409, { error: "install-running" }],
1088
+ });
1089
+ await ui.app.start();
1090
+ await flush();
1091
+ dispatch(button(ui.view, "Close installer") as FakeElement, "click");
1092
+ await flush();
1093
+ assert.match(ui.alert.textContent, /still running/i);
1094
+ assert.match(headings(ui.view)[0].textContent, /failed/i);
1095
+ assert.ok(button(ui.view, "Close installer"), "close stays available");
1096
+ });
1097
+
1098
+ test("Copy writes the command through the injected clipboard and reports success or failure", async () => {
1099
+ const writes: string[] = [];
1100
+ const ok = mount({ "GET /api/progress": () => outcomeProgress(readyOutcome) }, { clipboard: { writeText: async (value) => {
1101
+ writes.push(value);
1102
+ } } });
1103
+ await ok.app.start();
1104
+ await flush();
1105
+ const copy = button(ok.view, "Copy") as FakeElement;
1106
+ dispatch(copy, "click");
1107
+ await flush();
1108
+ assert.deepEqual(writes, ["gentle-shell"]);
1109
+ assert.equal(copy.textContent, "Copied");
1110
+ assert.equal(ok.status.textContent, "Command copied.");
1111
+
1112
+ const denied = mount({ "GET /api/progress": () => outcomeProgress(readyOutcome) }, { clipboard: { writeText: async () => {
1113
+ throw new Error("denied");
1114
+ } } });
1115
+ await denied.app.start();
1116
+ await flush();
1117
+ const failing = button(denied.view, "Copy") as FakeElement;
1118
+ dispatch(failing, "click");
1119
+ await flush();
1120
+ assert.equal(failing.textContent, "Copy failed");
1121
+ assert.notEqual(denied.status.textContent, "Command copied.");
1122
+
1123
+ const none = mount({ "GET /api/progress": () => outcomeProgress(readyOutcome) });
1124
+ await none.app.start();
1125
+ await flush();
1126
+ assert.equal(button(none.view, "Copy"), undefined, "no clipboard, no Copy button");
1127
+ assert.ok(none.view.textContent.includes("gentle-shell"), "the command is still shown");
1128
+ });
1129
+
1130
+ test("the PNPM_HOME blocker and the private PNPM_HOME step have labels", () => {
1131
+ const model = wizard.planModel({ planId: "p", ready: false, actions: [], blockers: [{ code: "untrusted-pnpm-home", tool: "pnpmHome", guidance: "g" }],
1132
+ profileChange: { changesProfile: false, binDir: null, description: "x" }, persistence: { tools: [], pnpmHome: null, description: "y" } });
1133
+ assert.equal(model.blockers[0].toolLabel, "pnpm home folder");
1134
+ assert.equal(wizard.stepLabel("prepare-pnpm-home"), "Create the private pnpm folder");
1135
+ });
1136
+
1137
+ // T1c: the bundled install's progress list, review copy and outcomes.
1138
+ const bundledIds = ["bundled-prefix", "bundled-runtime", "bundled-version", "bundled-activate", "bundled-path", "bundled-setup"];
1139
+ test("expectedSteps mirrors runBundledInstall's step order, and every bundled step has a label", async () => {
1140
+ const steps = ["claim-prefix", "copy-npm-settings", "install-runtime", "install-version", "activate-version", "write-launcher", "bundled-path", "shell-setup"];
1141
+ assert.deepEqual(wizard.expectedSteps(bundledIds), steps);
1142
+ assert.deepEqual(wizard.expectedSteps(bundledIds.filter((id) => id !== "bundled-path")), steps.filter((id) => id !== "bundled-path"));
1143
+ // verify-launcher runs only for a migration (T3).
1144
+ assert.deepEqual([...bundledFailedSteps], [...steps.slice(0, 6), "verify-launcher", ...steps.slice(6)]);
1145
+ for (const id of steps) assert.notEqual(wizard.stepLabel(id), id, `label for ${id}`);
1146
+ // The runner logs exactly that order.
1147
+ const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/u" });
1148
+ const distribution = { status: "published", manifest: { shell: "4.1.0", pi: "1.0.2" }, lockfile: "lock" };
1149
+ const path = { kind: "profile", path: "/home/u/.bashrc", line: "export PATH=x # gentle-shell bundled install", create: false };
1150
+ const plan = bundledPlan({ platform: "linux", distribution, layout, path });
1151
+ const logged: string[] = [];
1152
+ const operations = { claimPrefix: () => layout, userNpmrc: () => "", writeNpmrcAuth: () => {}, ensureRuntime: async () => ({ go: null }), installVersion: async () => ({}),
1153
+ activateVersion: () => {}, ensureLauncher: () => {}, pathEntryPlan: () => path, applyPathEntry: () => {}, run: async () => ({ status: 0 }) };
1154
+ await runBundledInstall({ plan, consent: true }, { platform: "linux", arch: "x64", env: {}, home: "/home/u", distribution, operations,
1155
+ log: (entry: { step: string }) => logged.push(entry.step) });
1156
+ assert.deepEqual(logged, wizard.expectedSteps(plan.actions.map((action: { id: string }) => action.id)));
1157
+ });
1158
+
1159
+ test("planModel and renderPlan present the bundled install as one self-contained product", () => {
1160
+ const view = { planId: "p", ready: false, bundled: true, channel: "release", blockers: [],
1161
+ actions: bundledIds.map((id) => ({ id, description: `${id} text` })),
1162
+ profileChange: { changesProfile: true, command: null, binDir: "/home/u/.gentle-shell/bin", description: "adds one line" },
1163
+ persistence: { tools: ["node", "npm", "pnpm"], pnpmHome: "/home/u/.gentle-shell", description: "Your own Node.js, npm, pnpm, Go and Pi are not used or changed." } };
1164
+ const model = wizard.planModel(view);
1165
+ assert.equal(model.kind, "bundled");
1166
+ const runtimes = model.disclosures.find((item: { id: string }) => item.id === "persistence");
1167
+ assert.equal(runtimes.title, "Gentle Shell's own runtimes");
1168
+ assert.equal(runtimes.state, "Uses its own node, npm, pnpm");
1169
+ assert.equal(runtimes.detail, "/home/u/.gentle-shell");
1170
+ assert.deepEqual(model.steps, wizard.expectedSteps(bundledIds));
1171
+ const node = wizard.renderPlan(new FakeDocument(), model, { install() {}, reload() {}, close() {} });
1172
+ assert.match(node.textContent, /Install Gentle Shell as one self-contained product/);
1173
+ assert.match(node.textContent, /your own Node\.js, npm, pnpm, Go and Pi are not used or changed/i);
1174
+ assert.match(node.textContent, /Install Gentle Shell/);
1175
+ // The default fresh-install path notes a missing Git too.
1176
+ const withGit = wizard.renderPlan(new FakeDocument(), wizard.planModel({ ...view, gitNotice: { description: "Git was not found on this computer." } }),
1177
+ { install() {}, reload() {}, close() {} });
1178
+ assert.match(withGit.textContent, /Git not found/);
1179
+ assert.doesNotMatch(node.textContent, /Git not found/);
1180
+ });
1181
+
1182
+ test("outcomeModel gives the PATH line to add when the bundled install may not edit the profile", () => {
1183
+ const line = 'export PATH="/home/u/.gentle-shell/bin:$PATH" # gentle-shell bundled install';
1184
+ const model = wizard.outcomeModel({ outcome: "terminal-action-required", action: "add-path-line", pathLine: { file: "/home/u/.zshrc", line },
1185
+ completed: ["claim-prefix"], guidance: guidance.outcomes["add-path-line"] });
1186
+ assert.equal(model.tone, "success");
1187
+ assert.equal(model.title, "Add Gentle Shell to your PATH");
1188
+ assert.equal(model.command, line);
1189
+ assert.ok(model.next.some((text: string) => text.includes("/home/u/.zshrc")));
1190
+ assert.ok(model.next.some((text: string) => /open a new terminal/i.test(text)));
1191
+ assert.equal(wizard.outcomeModel({ outcome: "failed", failedStep: "install-version", completed: [], guidance: "g", detail: "ERR_PNPM_FETCH_401" }).detailCommand,
1192
+ "pnpm install");
1193
+ });
1194
+
1195
+ // T3: a migration checks the new launcher, then removes the old global gentle-pi last.
1196
+ test("expectedSteps mirrors a migration's steps, and a failed removal shows its exact command", async () => {
1197
+ const ids = [...bundledIds, "remove-global-shell"];
1198
+ const steps = ["claim-prefix", "copy-npm-settings", "install-runtime", "install-version", "activate-version", "write-launcher", "verify-launcher", "bundled-path",
1199
+ "shell-setup", "remove-global-shell"];
1200
+ assert.deepEqual(wizard.expectedSteps(ids), steps);
1201
+ for (const id of ["verify-launcher", "remove-global-shell"]) assert.notEqual(wizard.stepLabel(id), id, `label for ${id}`);
1202
+ const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/u" });
1203
+ const distribution = { status: "published", manifest: { shell: "4.1.0", pi: "1.0.2" }, lockfile: "lock" };
1204
+ const path = { kind: "profile", path: "/home/u/.bashrc", line: "export PATH=x # gentle-shell bundled install", create: false };
1205
+ const plan = bundledPlan({ platform: "linux", distribution, layout, path, migrate: { manager: "pnpm", removal: "plan" } });
1206
+ assert.deepEqual(plan.actions.map((action: { id: string }) => action.id), ids);
1207
+ const logged: string[] = [];
1208
+ const operations = { claimPrefix: () => layout, userNpmrc: () => "", writeNpmrcAuth: () => {}, ensureRuntime: async () => ({ go: null }), installVersion: async () => ({}),
1209
+ activateVersion: () => {}, ensureLauncher: () => {}, pathEntryPlan: () => path, applyPathEntry: () => {}, checkLauncher: async () => ({ ok: true }),
1210
+ run: async () => ({ status: 0 }) };
1211
+ await runBundledInstall({ plan, consent: true }, { platform: "linux", arch: "x64", env: {}, home: "/home/u", distribution, operations,
1212
+ removeGlobal: async () => true, log: (entry: { step: string }) => logged.push(entry.step) });
1213
+ assert.deepEqual(logged, steps);
1214
+ const model = wizard.outcomeModel({ outcome: "ready", completed: [], guidance: "g", removeCommand: "pnpm remove -g gentle-pi" });
1215
+ assert.ok(model.notes.some((note: string) => note.includes("`pnpm remove -g gentle-pi`")));
1216
+ assert.deepEqual(wizard.outcomeModel({ outcome: "ready", completed: [], guidance: "g" }).notes, []);
1217
+ // B3: a failed migration step shows the host's detail line.
1218
+ for (const id of ["claim-prefix", "copy-npm-settings", "install-runtime", "activate-version", "write-launcher", "verify-launcher", "bundled-path"]) {
1219
+ const failed = wizard.outcomeModel({ outcome: "failed", failedStep: id, completed: [], guidance: "g", detail: "Unsafe bundled prefix: ~/.gentle-shell" });
1220
+ assert.equal(failed.detail, "Unsafe bundled prefix: ~/.gentle-shell", id);
1221
+ assert.equal(typeof failed.detailCommand, "string", id);
1222
+ }
1223
+ });