@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24

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 (327) hide show
  1. package/README.md +102 -194
  2. package/README.zh-CN.md +115 -210
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-bootstrap.js +466 -35
  5. package/bin/gotry-inner.js +255 -36
  6. package/bin/gotry-process-liveness.d.ts +27 -0
  7. package/bin/gotry-process-liveness.js +97 -0
  8. package/bin/gotry-runtime-resolution.d.ts +1 -1
  9. package/bin/gotry-runtime-resolution.js +2 -2
  10. package/client/client.js +174 -0
  11. package/cordis.gotry-patch.yml +70 -93
  12. package/dist/capabilities/agent-reach-deep.js +45 -122
  13. package/dist/capabilities/agent-reach.js +63 -148
  14. package/dist/capabilities/anything.js +89 -75
  15. package/dist/capabilities/artifacts.js +123 -104
  16. package/dist/capabilities/channel-health.js +65 -45
  17. package/dist/capabilities/channel-registry.js +108 -87
  18. package/dist/capabilities/doctor.js +357 -235
  19. package/dist/capabilities/effect.js +223 -309
  20. package/dist/capabilities/fact-log.js +28 -14
  21. package/dist/capabilities/flyai.js +245 -156
  22. package/dist/capabilities/ground-transfer.js +509 -0
  23. package/dist/capabilities/hbcli.js +144 -148
  24. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  25. package/dist/capabilities/incident-log.js +131 -61
  26. package/dist/capabilities/model-override.js +30 -10
  27. package/dist/capabilities/opensky.js +54 -43
  28. package/dist/capabilities/resilience.js +50 -32
  29. package/dist/capabilities/session/action-cache.js +50 -44
  30. package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
  31. package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
  32. package/dist/capabilities/session/adapters/dida-portal.js +206 -0
  33. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  34. package/dist/capabilities/session/adapters/rail-12306.js +169 -112
  35. package/dist/capabilities/session/benchmark.js +85 -149
  36. package/dist/capabilities/session/extension-bridge.js +320 -259
  37. package/dist/capabilities/session/extension-channel.js +57 -76
  38. package/dist/capabilities/session/extension-distribution.js +115 -119
  39. package/dist/capabilities/session/extract.js +31 -21
  40. package/dist/capabilities/session/golden-score.js +55 -33
  41. package/dist/capabilities/session/health-watch.js +146 -154
  42. package/dist/capabilities/session/read-guard.js +55 -47
  43. package/dist/capabilities/session/static-flight-golden.js +49 -36
  44. package/dist/capabilities/session/transport.js +54 -61
  45. package/dist/capabilities/session/wizard.js +63 -93
  46. package/dist/capabilities/session-consent.js +143 -47
  47. package/dist/capabilities/session-login.js +95 -126
  48. package/dist/capabilities/session-search.js +391 -253
  49. package/dist/capabilities/sponsor-plugin.js +341 -0
  50. package/dist/capabilities/visa-policy.js +87 -73
  51. package/dist/capabilities/weather.js +163 -235
  52. package/dist/scripts/action-cache-tests.js +24 -59
  53. package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
  54. package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
  55. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  56. package/dist/scripts/agent-reach-tests.js +26 -18
  57. package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
  58. package/dist/scripts/anything-tests.js +58 -62
  59. package/dist/scripts/artifact-client-contract-tests.js +69 -0
  60. package/dist/scripts/artifacts-capability-tests.js +235 -0
  61. package/dist/scripts/async-collect.js +26 -15
  62. package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
  63. package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
  64. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
  65. package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
  66. package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
  67. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
  68. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
  69. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
  70. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
  71. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
  72. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
  73. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
  74. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
  75. package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
  76. package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
  77. package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
  78. package/dist/scripts/booking-executor-tests.js +80 -0
  79. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  80. package/dist/scripts/booking-saga-tests.js +61 -123
  81. package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
  82. package/dist/scripts/bootstrap-tests.js +1383 -128
  83. package/dist/scripts/build-changelog.js +89 -130
  84. package/dist/scripts/build-metrics-report.js +135 -143
  85. package/dist/scripts/changelog-tests.js +35 -82
  86. package/dist/scripts/channel-probe-tests.js +44 -116
  87. package/dist/scripts/channel-probe.js +84 -167
  88. package/dist/scripts/channel-registry-tests.js +97 -185
  89. package/dist/scripts/companion-tests.js +25 -70
  90. package/dist/scripts/dida-cookie-probe.js +87 -0
  91. package/dist/scripts/dida-sw-probe.js +114 -0
  92. package/dist/scripts/diff-test.js +17 -12
  93. package/dist/scripts/doctor-tests.js +428 -120
  94. package/dist/scripts/dsh-artifact-e2e.js +150 -0
  95. package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
  96. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  97. package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
  98. package/dist/scripts/dsh-runtime-closure.js +75 -63
  99. package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
  100. package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
  101. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  102. package/dist/scripts/effect-tests.js +165 -552
  103. package/dist/scripts/engine-run.js +1 -3
  104. package/dist/scripts/engine-tests.js +16 -10
  105. package/dist/scripts/evaluation-cadence-tests.js +69 -242
  106. package/dist/scripts/evaluation-contract-tests.js +193 -499
  107. package/dist/scripts/extension-distribution-cli.js +19 -15
  108. package/dist/scripts/extension-distribution-tests.js +98 -211
  109. package/dist/scripts/extension-tests.js +366 -389
  110. package/dist/scripts/fact-gate-tests.js +725 -263
  111. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  112. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  113. package/dist/scripts/flight-malformed-tests.js +159 -0
  114. package/dist/scripts/flyai-tests.js +325 -68
  115. package/dist/scripts/g5-guard-tests.js +131 -0
  116. package/dist/scripts/g5-guard.js +189 -0
  117. package/dist/scripts/gen-lunar-anchors.js +106 -0
  118. package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
  119. package/dist/scripts/ground-transfer-tests.js +956 -0
  120. package/dist/scripts/hbcli-e2e-tests.js +71 -90
  121. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  122. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  123. package/dist/scripts/hbcli-tests.js +41 -115
  124. package/dist/scripts/health-watch-cli.js +42 -28
  125. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  126. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  127. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  128. package/dist/scripts/i18n-tests.js +16 -9
  129. package/dist/scripts/incident-tests.js +281 -87
  130. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  131. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  132. package/dist/scripts/issue-271-liveness-tests.js +547 -0
  133. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  134. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  135. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  136. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  137. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  138. package/dist/scripts/journey-tests.js +22 -27
  139. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  140. package/dist/scripts/kernel-manifest-gate.js +126 -0
  141. package/dist/scripts/kernel-manifest-tests.js +212 -0
  142. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  143. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  144. package/dist/scripts/kernel-manifest.js +369 -0
  145. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  146. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  147. package/dist/scripts/ledger-tests.js +325 -340
  148. package/dist/scripts/ledger-workflow-crash.js +10 -20
  149. package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
  150. package/dist/scripts/memory-capture-tests.js +242 -61
  151. package/dist/scripts/memory-decay-tests.js +18 -52
  152. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  153. package/dist/scripts/memory-lifecycle.js +196 -0
  154. package/dist/scripts/memory-metrics.js +24 -12
  155. package/dist/scripts/memory-value-report.js +474 -222
  156. package/dist/scripts/memory-value-tests.js +328 -0
  157. package/dist/scripts/metrics-report-tests.js +62 -199
  158. package/dist/scripts/model-override-e2e.js +66 -105
  159. package/dist/scripts/nightly-evidence-tests.js +64 -89
  160. package/dist/scripts/nightly-evidence.js +108 -111
  161. package/dist/scripts/nudge-digest.js +39 -28
  162. package/dist/scripts/onboarding-tests.js +63 -88
  163. package/dist/scripts/opensky-check.js +18 -13
  164. package/dist/scripts/opensky-tests.js +28 -28
  165. package/dist/scripts/persona-surface-guard-tests.js +37 -12
  166. package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
  167. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  168. package/dist/scripts/price-drift-tests.js +126 -227
  169. package/dist/scripts/price-drift-watch.js +209 -253
  170. package/dist/scripts/probe-poi-tests.js +52 -45
  171. package/dist/scripts/product-metrics.js +187 -273
  172. package/dist/scripts/publish-preverify.js +47 -41
  173. package/dist/scripts/realtime-pricing-tests.js +37 -76
  174. package/dist/scripts/replay-async.js +20 -18
  175. package/dist/scripts/replay-real.js +16 -18
  176. package/dist/scripts/replay.js +52 -82
  177. package/dist/scripts/session-attach-diagnose.js +16 -12
  178. package/dist/scripts/session-attach-poc.js +34 -37
  179. package/dist/scripts/session-benchmark.js +66 -147
  180. package/dist/scripts/session-dida-live-e2e.js +151 -0
  181. package/dist/scripts/session-extract-tests.js +14 -35
  182. package/dist/scripts/session-login.js +14 -13
  183. package/dist/scripts/session-tests.js +545 -583
  184. package/dist/scripts/sf-live-benchmark.js +92 -110
  185. package/dist/scripts/sf-live-cli-tests.js +2 -9
  186. package/dist/scripts/sf-soft-score-tests.js +19 -60
  187. package/dist/scripts/sf-summary-tests.js +149 -0
  188. package/dist/scripts/sf-summary.js +466 -84
  189. package/dist/scripts/skeleton-check.js +16 -14
  190. package/dist/scripts/skeleton-integration-test.js +11 -10
  191. package/dist/scripts/skills-contract-tests.js +51 -46
  192. package/dist/scripts/smoke-session-gate-tests.js +12 -20
  193. package/dist/scripts/smoke.js +541 -422
  194. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  195. package/dist/scripts/state-cli-tests.js +226 -101
  196. package/dist/scripts/state-cli.js +549 -212
  197. package/dist/scripts/static-golden-tests.js +46 -180
  198. package/dist/scripts/time-eval-tests.js +210 -192
  199. package/dist/scripts/travel-timeline-tests.js +29 -93
  200. package/dist/scripts/turn-handoff-collect-tests.js +46 -93
  201. package/dist/scripts/turn-handoff-collect.js +71 -99
  202. package/dist/scripts/turn-policy-tests.js +28 -29
  203. package/dist/scripts/typed-contract-canary.js +77 -162
  204. package/dist/scripts/tz-resolver-sanity.js +50 -0
  205. package/dist/scripts/tz-resolver-tests.js +584 -0
  206. package/dist/scripts/unified-tests.js +23 -19
  207. package/dist/scripts/visa-policy-tests.js +31 -44
  208. package/dist/scripts/weather-tests.js +121 -543
  209. package/dist/scripts/wish-channel-gate-tests.js +29 -86
  210. package/dist/scripts/write-gate-tests.js +522 -0
  211. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  212. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  213. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  214. package/dist/scripts/z3-race-tests.js +18 -12
  215. package/dist/src/artifact-gate.js +652 -221
  216. package/dist/src/backend/kernel.js +91 -0
  217. package/dist/src/backend/modules/booking-copilot.js +55 -0
  218. package/dist/src/backend/modules/booking-executor.js +124 -0
  219. package/dist/src/backend/modules/session-search.js +194 -0
  220. package/dist/src/benchmark-agent-conformance.js +296 -262
  221. package/dist/src/benchmark-environment-bridge.js +298 -413
  222. package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
  223. package/dist/src/benchmark-tool-isolation.js +94 -67
  224. package/dist/src/bookable-facts.js +299 -174
  225. package/dist/src/booking-saga.js +64 -120
  226. package/dist/src/booking-surface/availability-policy.js +443 -576
  227. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  228. package/dist/src/booking-surface/contracts.js +24 -80
  229. package/dist/src/booking-surface/dsh-planner.js +510 -281
  230. package/dist/src/booking-surface/dsh-plugin.js +17 -1
  231. package/dist/src/booking-surface/error-codes.js +82 -10
  232. package/dist/src/booking-surface/index.js +0 -3
  233. package/dist/src/booking-surface/profile.js +10 -54
  234. package/dist/src/booking-surface/recovery-chain.js +367 -0
  235. package/dist/src/booking-surface/runtime.js +765 -891
  236. package/dist/src/booking-surface/server.js +200 -251
  237. package/dist/src/booking-surface/startup.js +58 -54
  238. package/dist/src/booking-surface/validation.js +150 -218
  239. package/dist/src/bridge.js +20 -13
  240. package/dist/src/companions.js +48 -74
  241. package/dist/src/contracts.js +21 -43
  242. package/dist/src/dsh-llm.js +82 -120
  243. package/dist/src/engine.js +253 -239
  244. package/dist/src/evaluation-cadence.js +72 -209
  245. package/dist/src/evaluation-contracts.js +220 -843
  246. package/dist/src/flight-pack-adapter.js +54 -0
  247. package/dist/src/flight-pack-contract.js +2 -0
  248. package/dist/src/gotry-backend.js +70 -0
  249. package/dist/src/hotel-date-gate.js +118 -0
  250. package/dist/src/i18n.js +18 -7
  251. package/dist/src/index.js +1425 -1629
  252. package/dist/src/journey.js +132 -81
  253. package/dist/src/ledger-repair-apply.js +538 -0
  254. package/dist/src/ledger-repair-plan.js +463 -0
  255. package/dist/src/loop.js +257 -166
  256. package/dist/src/memory-capture.js +180 -32
  257. package/dist/src/memory-decay.js +30 -14
  258. package/dist/src/memory-lifecycle.js +1310 -0
  259. package/dist/src/memory-utility.js +27 -40
  260. package/dist/src/mock-llm.js +36 -61
  261. package/dist/src/model.js +81 -36
  262. package/dist/src/realtime-pricing.js +63 -72
  263. package/dist/src/slot-spec.js +84 -106
  264. package/dist/src/state-ledger.js +310 -471
  265. package/dist/src/subagent-job-id-guard.js +58 -0
  266. package/dist/src/time-anchor.js +231 -33
  267. package/dist/src/tool-packet.js +20 -8
  268. package/dist/src/travel-slots.js +58 -35
  269. package/dist/src/travel-timeline.js +44 -63
  270. package/dist/src/turn-deadline.js +121 -127
  271. package/dist/src/turn-policy.js +61 -89
  272. package/dist/src/tz-resolver.js +143 -0
  273. package/dist/src/unified.js +504 -381
  274. package/dist/src/wish-pool.js +39 -14
  275. package/dist/src/write-gate.js +495 -0
  276. package/dist/src/z3-shared.js +273 -7
  277. package/extension/README.md +4 -3
  278. package/extension/background.js +125 -14
  279. package/extension/content-bridge.js +23 -1
  280. package/extension/content-main.js +15 -4
  281. package/extension/manifest.json +16 -8
  282. package/package.json +251 -220
  283. package/ts/capabilities/agent-reach.ts +4 -4
  284. package/ts/capabilities/anything.ts +1 -1
  285. package/ts/capabilities/artifacts.ts +46 -9
  286. package/ts/capabilities/flyai.ts +156 -37
  287. package/ts/capabilities/hbcli.ts +1 -1
  288. package/ts/capabilities/incident-log.ts +88 -59
  289. package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
  290. package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
  291. package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
  292. package/ts/capabilities/session/extension-bridge.ts +226 -101
  293. package/ts/capabilities/session/extension-channel.ts +22 -9
  294. package/ts/capabilities/session/wizard.ts +2 -2
  295. package/ts/capabilities/session-consent.ts +98 -14
  296. package/ts/capabilities/session-login.ts +1 -0
  297. package/ts/capabilities/session-search.ts +315 -31
  298. package/ts/dsh-runtime/vendor/README.md +8 -4
  299. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
  300. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
  301. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
  302. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
  303. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
  304. package/ts/package.json +236 -16
  305. package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
  306. package/ts/scripts/state-cli.ts +418 -51
  307. package/ts/src/artifact-gate.ts +561 -15
  308. package/ts/src/benchmark-environment-bridge.ts +37 -20
  309. package/ts/src/bookable-facts.ts +183 -7
  310. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  311. package/ts/src/booking-surface/dsh-planner.ts +240 -32
  312. package/ts/src/booking-surface/dsh-plugin.js +17 -1
  313. package/ts/src/booking-surface/error-codes.ts +75 -0
  314. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  315. package/ts/src/booking-surface/server.ts +84 -51
  316. package/ts/src/booking-surface/validation.ts +2 -0
  317. package/ts/src/contracts.ts +12 -2
  318. package/ts/src/dsh-llm.ts +4 -8
  319. package/ts/src/index.ts +609 -90
  320. package/ts/src/loop.ts +116 -7
  321. package/ts/src/memory-capture.ts +227 -3
  322. package/ts/src/mock-llm.ts +4 -8
  323. package/ts/src/model.ts +88 -2
  324. package/ts/src/state-ledger.ts +47 -29
  325. package/ts/src/subagent-job-id-guard.ts +72 -0
  326. package/ts/src/time-anchor.ts +217 -13
  327. package/ts/src/unified.ts +316 -54
@@ -1,113 +1,102 @@
1
+ /**
2
+ * bootstrap 自举层测试(bin/gotry-bootstrap.js,零网络安装、只探测/跳过开关)。
3
+ * setup 面只剩扩展是否就位(2026-09-02 商店上架后职责返交 #2);可选依赖
4
+ * (hbcli / agent-reach / dsh-better-sidebar)的状态与补装由 doctor 面接管
5
+ * (founder 2026-09-02 拍板:不撒手,体检+引导;LLM key 仍归 dsh 宿主管)。
6
+ * 守:
7
+ * 1. --check-only:只探测报告不安装,exit 0,扩展就位节存在
8
+ * 2. GOTRY_SETUP_SKIP=1 + --auto:跳过
9
+ * 3. 显式模式 GOTRY_SETUP_SKIP=1:同样跳过
10
+ * 4. GOTRY_SETUP_EXTENSION=0 单项跳过
11
+ * 5. wizard --dry-run 与真实路径(probe 失败 exit 1)
12
+ * 8. doctor 子命令:体检清单/LLM key 让渡/报告落盘(状态面回归)
13
+ *
14
+ * 不测真实安装(浏览器商店一键装已上架,扩展就位检测走 runHealthWatch 的回环端口)。
15
+ * 运行: cd ts && npx tsx scripts/bootstrap-tests.ts
16
+ */
1
17
  import assert from 'node:assert/strict';
2
- import { execFileSync, spawnSync } from 'node:child_process';
3
- import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { execFileSync, spawnSync, spawn } from 'node:child_process';
19
+ import { EventEmitter } from 'node:events';
20
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
21
  import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
22
+ import { join, dirname, delimiter } from 'node:path';
6
23
  const repoRoot = join(import.meta.dirname, '..', '..');
7
24
  const bootstrap = join(repoRoot, 'bin', 'gotry-bootstrap.js');
8
- function runBootstrap(extraArgs, extraEnv) {
25
+ function runBootstrap(extraArgs, extraEnv, bootstrapPath = bootstrap) {
9
26
  try {
10
- const out = execFileSync('node', [
11
- bootstrap,
12
- ...extraArgs
13
- ], {
27
+ const out = execFileSync('node', [bootstrapPath, ...extraArgs], {
14
28
  encoding: 'utf-8',
15
29
  timeout: 60_000,
16
- env: {
17
- ...process.env,
18
- ...extraEnv
19
- }
30
+ env: { ...process.env, ...extraEnv },
20
31
  });
21
- return {
22
- code: 0,
23
- out
24
- };
25
- } catch (e) {
32
+ return { code: 0, out };
33
+ }
34
+ catch (e) {
26
35
  const err = e;
27
- return {
28
- code: err.status ?? 1,
29
- out: err.stdout ?? ''
30
- };
36
+ return { code: err.status ?? 1, out: err.stdout ?? '' };
31
37
  }
32
38
  }
33
- const c1 = runBootstrap([
34
- '--check-only'
35
- ], {});
39
+ // 1. --check-only:扩展就位探测,exit 0(已不替用户管 hbcli/agent-reach/sidebar)
40
+ const c1 = runBootstrap(['--check-only'], {});
36
41
  assert.equal(c1.code, 0, `--check-only 应 exit 0,实际 ${c1.code}\n${c1.out}`);
37
42
  assert.ok(c1.out.includes('Session Bridge'), '报告应含扩展就位节(issue #21 传输层方案 C)');
38
43
  assert.ok(!c1.out.includes('hbcli'), 'setup 不再替用户管 hbcli(已让出)');
39
44
  assert.ok(!c1.out.includes('agent-reach'), 'setup 不再替用户管 agent-reach(已让出)');
40
45
  assert.ok(!c1.out.includes('dsh-better-sidebar'), 'setup 不再替用户管 dsh-better-sidebar(已让出)');
41
46
  console.log('1. --check-only 探测报告 exit 0(扩展就位 + 让出 hbcli/agent-reach/sidebar)OK');
42
- const c2 = runBootstrap([
43
- '--auto'
44
- ], {
45
- GOTRY_SETUP_SKIP: '1'
46
- });
47
+ // 2. --auto + GOTRY_SETUP_SKIP=1:跳过
48
+ const c2 = runBootstrap(['--auto'], { GOTRY_SETUP_SKIP: '1' });
47
49
  assert.equal(c2.code, 0, '--auto 跳过态应 exit 0');
48
50
  assert.ok(c2.out.includes('跳过'), '应输出跳过说明');
49
51
  console.log('2. --auto + GOTRY_SETUP_SKIP=1 → exit 0(永不挡 npm install)OK');
50
- const c3 = runBootstrap([], {
51
- GOTRY_SETUP_SKIP: '1'
52
- });
52
+ // 3. 显式模式 + GOTRY_SETUP_SKIP=1:同样跳过
53
+ const c3 = runBootstrap([], { GOTRY_SETUP_SKIP: '1' });
53
54
  assert.equal(c3.code, 0, '显式模式跳过态应 exit 0');
54
55
  assert.ok(c3.out.includes('跳过'), '应输出跳过说明');
55
56
  console.log('3. 显式模式 + GOTRY_SETUP_SKIP=1 → exit 0 OK');
56
- const c4 = runBootstrap([
57
- '--check-only'
58
- ], {
59
- GOTRY_SETUP_EXTENSION: '0'
60
- });
57
+ // 4. GOTRY_SETUP_EXTENSION=0 单项跳过
58
+ const c4 = runBootstrap(['--check-only'], { GOTRY_SETUP_EXTENSION: '0' });
61
59
  assert.equal(c4.code, 0, '单项跳过态应 exit 0');
62
60
  assert.ok(c4.out.includes('GOTRY_SETUP_EXTENSION=0 跳过'), '应输出扩展单项跳过说明');
63
61
  console.log('4. GOTRY_SETUP_EXTENSION=0 单项跳过 OK');
64
- const c5 = runBootstrap([
65
- 'wizard',
66
- '--dry-run'
67
- ], {});
62
+ // 5. wizard 子命令(2026-09-02 商店上架后退化):dry-run 模式零网络零浏览器零剪贴板,exit 0,2 步齐全
63
+ const c5 = runBootstrap(['wizard', '--dry-run'], {});
68
64
  assert.equal(c5.code, 0, `wizard --dry-run 应 exit 0,实际 ${c5.code}\n${c5.out}`);
69
65
  assert.ok(c5.out.includes('dry-run'), '应输出 dry-run 字样');
70
66
  assert.ok(c5.out.includes('gotry-wizard'), '应输出 [gotry-wizard] 标签');
71
67
  assert.ok(c5.out.includes('ensure-extension-files'), 'dry-run 应列 ensure-extension-files');
72
68
  assert.ok(c5.out.includes('watch-extension-ready'), 'dry-run 应列 watch-extension-ready');
73
69
  console.log('5. wizard 子命令(--dry-run 零网络,2 步齐全 + 极简 stdout)OK');
74
- const c6 = runBootstrap([
75
- 'wizard'
76
- ], {
70
+ // 6. wizard 走真实路径但 timeout 极短(GOTRY_ONBOARDING_TIMEOUT_MS 缺省走 120s,降级由 inline 探活兜),
71
+ // 端口 0 让子进程绑定隔离的临时桥,避免本机已在线扩展污染 timeout 负例(issue #209)。
72
+ // 确认 stdout 至少含一次探活心跳 + 引导标题(不再断言 "3 步",纯 stdout 形态下标题文案已简化)
73
+ const c6 = runBootstrap(['wizard'], {
77
74
  GOTRY_SETUP_EXTENSION: '0',
78
75
  GOTRY_ONBOARDING_TIMEOUT_MS: '600',
79
76
  GOTRY_ONBOARDING_INTERVAL_MS: '200',
80
- GOTRY_ONBOARDING_BRIDGE_PORTS: '0'
77
+ GOTRY_ONBOARDING_BRIDGE_PORTS: '0',
81
78
  });
82
79
  assert.equal(c6.code, 1, `wizard(超时)应 exit 1,实际 ${c6.code}\n${c6.out}`);
83
80
  assert.ok(c6.out.includes('gotry-wizard'), '应输出 [gotry-wizard] 标签');
84
81
  console.log('6. wizard 真实路径(扩展未就绪,exit 1 + 心跳)OK');
85
- const c7 = runBootstrap([
86
- '--check-only',
87
- '--extension-from=github'
88
- ], {
89
- GOTRY_SETUP_HBCLI: '0',
90
- GOTRY_SETUP_REACH: '0',
91
- GOTRY_SETUP_SIDEBAR: '0',
92
- GOTRY_EXTENSION_RELEASE_BASE: 'http://127.0.0.1:1/releases'
82
+ // 7. 扩展分发 github 通道(ADR-21):基址指不可达回环(127.0.0.1:1 拒连,离线确定性);显式模式不带 --auto——CI 环境里 AUTO+CI 会提前跳过全部节,断言面会落空
83
+ // → 显式降级 bundled + check-only 报告,exit 0;非法 --extension-from 值回落 bundled 不进网络通道。
84
+ const c7 = runBootstrap(['--check-only', '--extension-from=github'], {
85
+ GOTRY_SETUP_HBCLI: '0', GOTRY_SETUP_REACH: '0', GOTRY_SETUP_SIDEBAR: '0',
86
+ GOTRY_EXTENSION_RELEASE_BASE: 'http://127.0.0.1:1/releases',
93
87
  });
94
88
  assert.equal(c7.code, 0, `github 通道降级应 exit 0,实际 ${c7.code}\n${c7.out}`);
95
89
  assert.ok(c7.out.includes('GitHub Releases 下载通道'), '应打印 github 通道标题');
96
90
  assert.ok(c7.out.includes('降级包内副本'), '失败应显式降级 bundled');
97
- const c7b = runBootstrap([
98
- '--check-only',
99
- '--extension-from=不合法值'
100
- ], {
101
- GOTRY_SETUP_HBCLI: '0',
102
- GOTRY_SETUP_REACH: '0',
103
- GOTRY_SETUP_SIDEBAR: '0'
91
+ const c7b = runBootstrap(['--check-only', '--extension-from=不合法值'], {
92
+ GOTRY_SETUP_HBCLI: '0', GOTRY_SETUP_REACH: '0', GOTRY_SETUP_SIDEBAR: '0',
104
93
  });
105
94
  assert.equal(c7b.code, 0, `非法 --extension-from 值应回落 bundled 且 exit 0,实际 ${c7b.code}\n${c7b.out}`);
106
95
  assert.ok(!c7b.out.includes('下载通道'), '非法值不应进入 github 通道');
107
96
  console.log('7. 扩展分发 github 通道(不可达基址即时降级 + 非法值回落 bundled)OK');
108
- const c8 = runBootstrap([
109
- 'doctor'
110
- ], {});
97
+ // 8. doctor 子命令(2026-09-02 迪拜 session 复盘:可选依赖不撒手——doctor 统一
98
+ // 显示状态 + 精确补装指引;LLM key 显式让渡;报告落 gotry-state/doctor-report.md)
99
+ const c8 = runBootstrap(['doctor'], {});
111
100
  assert.ok(c8.out.includes('[gotry-doctor]'), '应输出 [gotry-doctor] 标签');
112
101
  assert.ok(c8.out.includes('Agent Reach'), '体检应含 agent-reach 项');
113
102
  assert.ok(c8.out.includes('hbcli'), '体检应含 hbcli 项');
@@ -116,70 +105,45 @@ assert.ok(c8.out.includes('LLM key'), '体检应含 LLM key 让渡说明(doctor
116
105
  assert.ok(c8.out.includes('dsh-map-tools'), '体检应含 dsh-map-tools 项(#139;与 ts/capabilities/doctor.ts 两面成对)');
117
106
  assert.ok(c8.out.includes('dsh-tool-ask-user'), '体检应含 dsh-tool-ask-user 项(与 ts/capabilities/doctor.ts 两面成对)');
118
107
  assert.ok(c8.out.includes('doctor-report.md'), '应提示报告落盘路径');
119
- assert.ok([
120
- 0,
121
- 1
122
- ].includes(c8.code), `doctor exit 应为 0(就绪)或 1(有缺失),实际 ${c8.code}`);
108
+ assert.ok([0, 1].includes(c8.code), `doctor exit 应为 0(就绪)或 1(有缺失),实际 ${c8.code}`);
123
109
  const reportPath = join(repoRoot, 'gotry-state', 'doctor-report.md');
124
110
  const report = readFileSync(reportPath, 'utf-8');
125
111
  assert.ok(report.includes('# GoTry 依赖体检报告'), '报告 markdown 应落盘可预览');
126
- assert.ok(report.includes('npx gotry doctor --fix'), '报告应带补装指引');
112
+ assert.ok(report.includes('npx @danceiny/gotry doctor --fix'), '报告应带补装指引');
127
113
  console.log('8. doctor 子命令(体检清单 + LLM key 让渡 + 报告落盘)OK');
114
+ // 9. calendar 子命令(issue #106/D-9:setup 状态面,禁止 env 控制产品行为)。
115
+ // HOME 隔离到 tmp(os.homedir() 尊重 $HOME):状态文件 ~/.gotry/calendar.json
116
+ // 的 on/off/status 三态全部走真实 bootstrap 路径验证。
128
117
  const calHome = mkdtempSync(join(tmpdir(), 'gotry-cal-test-'));
129
- const calEnv = {
130
- HOME: calHome
131
- };
132
- const c9a = runBootstrap([
133
- 'calendar',
134
- '--status'
135
- ], calEnv);
118
+ const calEnv = { HOME: calHome };
119
+ const c9a = runBootstrap(['calendar', '--status'], calEnv);
136
120
  assert.equal(c9a.code, 0, `calendar --status(默认态)应 exit 0\n${c9a.out}`);
137
121
  assert.ok(c9a.out.includes('默认未挂载'), '默认态=未挂载');
138
122
  assert.ok(c9a.out.includes(join(calHome, '.gotry', 'calendar.json')), '状态文件路径可见');
139
- const c9b = runBootstrap([
140
- 'calendar'
141
- ], calEnv);
123
+ const c9b = runBootstrap(['calendar'], calEnv);
142
124
  assert.equal(c9b.code, 0, `calendar(开启)应 exit 0\n${c9b.out}`);
143
125
  assert.ok(c9b.out.includes('已开启挂载'), '开启态输出');
144
126
  assert.ok(c9b.out.includes('cordis.patch.yml'), '未配置时给 profile 配置指引');
145
- const c9c = runBootstrap([
146
- 'calendar',
147
- '--status'
148
- ], calEnv);
127
+ const c9c = runBootstrap(['calendar', '--status'], calEnv);
149
128
  assert.ok(c9c.out.includes('已挂载'), '开启后 --status 显示已挂载');
150
- const c9d = runBootstrap([
151
- 'doctor'
152
- ], {
153
- ...calEnv,
154
- GOTRY_SETUP_SKIP: '1'
155
- });
129
+ const c9d = runBootstrap(['doctor'], { ...calEnv, GOTRY_SETUP_SKIP: '1' });
156
130
  assert.ok(c9d.out.includes('dsh-calendar'), 'doctor 清单含 calendar 项(setup 状态面)');
157
131
  assert.ok(c9d.out.includes('已挂载但 calendar 未配置'), '开启未配置=doctor 可见');
158
- const c9e = runBootstrap([
159
- 'calendar',
160
- '--off'
161
- ], calEnv);
132
+ const c9e = runBootstrap(['calendar', '--off'], calEnv);
162
133
  assert.equal(c9e.code, 0, `calendar --off 应 exit 0\n${c9e.out}`);
163
134
  assert.ok(c9e.out.includes('恢复默认不挂载'), '关闭态输出');
164
- const c9f = runBootstrap([
165
- 'calendar',
166
- '--status'
167
- ], calEnv);
135
+ const c9f = runBootstrap(['calendar', '--status'], calEnv);
168
136
  assert.ok(c9f.out.includes('默认未挂载'), '关闭后回到默认态');
169
137
  console.log('9. calendar 子命令(setup 状态面 on/off/status + doctor 三态,HOME 隔离)OK');
138
+ // 10. 启动一次性 doctor 摘要(issue #114,design §3.1③):inner 在 dsh 启动前以分离
139
+ // 子进程跑 `doctor --summary`——有待处理项一行 stderr,全 ok 静默;零 header 零写盘,
140
+ // 恒 exit 0(不挡启动语义)。HOME 隔离到 tmp 保证缺失项确定性。
170
141
  {
171
142
  const sumHome = mkdtempSync(join(tmpdir(), 'gotry-sum-test-'));
172
- const r = spawnSync('node', [
173
- bootstrap,
174
- 'doctor',
175
- '--summary'
176
- ], {
143
+ const r = spawnSync('node', [bootstrap, 'doctor', '--summary'], {
177
144
  encoding: 'utf-8',
178
145
  timeout: 60_000,
179
- env: {
180
- ...process.env,
181
- HOME: sumHome
182
- }
146
+ env: { ...process.env, HOME: sumHome },
183
147
  });
184
148
  assert.equal(r.status, 0, `doctor --summary 应恒 exit 0(不挡启动),实际 ${r.status}\n${r.stderr}`);
185
149
  assert.ok(!r.stdout.includes('报告已写'), 'summary 模式零写盘(不落 doctor-report.md)');
@@ -187,34 +151,1325 @@ console.log('9. calendar 子命令(setup 状态面 on/off/status + doctor 三态
187
151
  assert.match(r.stderr, /\[gotry\] doctor: \d+ 项待处理/, '隔离 HOME 有缺失项 → 一行摘要进 stderr');
188
152
  assert.match(r.stderr, /gotry_doctor/, '摘要带对话内指路');
189
153
  assert.match(r.stderr, /扩展=缺/, '缺失项人话=缺(降级类=半可用)');
190
- }console.log('10. 启动一次性 doctor 摘要(--summary:stderr 一行/零写盘/恒 exit 0)OK');
154
+ }
155
+ console.log('10. 启动一次性 doctor 摘要(--summary:stderr 一行/零写盘/恒 exit 0)OK');
156
+ // 11. setupSidebar 落盘状态复核(2026-09-08 安装链修复):pnpm ≥10.5 在 profile 目录
157
+ // 不执行依赖构建脚本,严格态(pnpm 11)下 ERR_PNPM_IGNORED_BUILDS exit 1——但包已
158
+ // 完整落盘。安装器必须按落盘状态(与 doctor 同口径)判成功,不以 dsh→pnpm 两层
159
+ // 转手的 exit code 误报失败。注入 attemptInstall 模拟「安装器失败但状态落盘」。
191
160
  {
192
161
  const sbHome = mkdtempSync(join(tmpdir(), 'gotry-sidebar-test-'));
193
162
  const prevHome = process.env.HOME;
194
- process.env.HOME = sbHome;
163
+ process.env.HOME = sbHome; // os.homedir() 在 POSIX 尊重 $HOME
195
164
  try {
196
165
  const { setupSidebar } = await import(bootstrap);
197
- const r = await setupSidebar(async ()=>{
166
+ // 模拟 pnpm 严格态:包完整落盘后 exit 1(ERR_PNPM_IGNORED_BUILDS)——落盘动作
167
+ // 发生在安装器内部,调用前 profile 是空的,才会走到「失败→复核」新路径
168
+ const r = await setupSidebar(async () => {
198
169
  const sbPkgDir = join(sbHome, '.dsh/profiles/web/node_modules/dsh-better-sidebar');
199
- mkdirSync(sbPkgDir, {
200
- recursive: true
201
- });
202
- writeFileSync(join(sbPkgDir, 'package.json'), JSON.stringify({
203
- name: 'dsh-better-sidebar',
204
- version: '0.18.0'
205
- }));
206
- return {
207
- ok: false,
208
- error: 'exit 1'
209
- };
170
+ mkdirSync(sbPkgDir, { recursive: true });
171
+ writeFileSync(join(sbPkgDir, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar', version: '0.18.0' }));
172
+ return { ok: false, error: 'exit 1' };
210
173
  });
211
174
  assert.equal(r.ok, true, '安装器 exit 非 0 但状态已落盘 → 按落盘判成功(不误报失败)');
212
- } finally{
213
- if (prevHome === undefined) delete process.env.HOME;
214
- else process.env.HOME = prevHome;
215
- }
216
- }console.log('11. setupSidebar 落盘状态复核(pnpm 忽略构建脚本 exit 1 不再误报)OK');
217
- console.log('BOOTSTRAP TESTS: 11/11 OK(扩展就位 + 跳过开关 / wizard --dry-run / wizard 真实 / 扩展分发通道 / doctor 体检面 / calendar setup 状态面 / 显式跳过 + auto 跳过 + 单项跳过 / 启动摘要 / sidebar 落盘状态复核)');
218
-
219
-
220
- //# sourceURL=ts/scripts/bootstrap-tests.ts
175
+ }
176
+ finally {
177
+ if (prevHome === undefined)
178
+ delete process.env.HOME;
179
+ else
180
+ process.env.HOME = prevHome;
181
+ }
182
+ }
183
+ console.log('11. setupSidebar 落盘状态复核(pnpm 忽略构建脚本 exit 1 不再误报)OK');
184
+ // --- issue #258 web 启动交互式 onboarding(隔离 HOME/state + 注入 fakes,永不跑真安装器/开浏览器)---
185
+ // 合成 doctor items(与 bin/gotry-bootstrap.js doctorChecks 的 label/level 同形),供 12-17 复用。
186
+ // 真实 doctor 不会同帧返回同一 label 的两态(hbcli 是单条 missing 或 degraded),故此处 hbcli
187
+ // 只取 missing 一态;degraded 分类在 12 里用独立 item 单测,避免 label-keyed Map 碰撞。
188
+ const onboardingItems = [
189
+ { label: 'Node 运行时', level: 'ok', detail: 'Node 22.x', fix: undefined },
190
+ { label: 'GoTry Session Bridge 扩展', level: 'missing', detail: '未安装', fix: 'https://chromewebstore...' },
191
+ { label: 'Agent Reach(网页/社媒读取)', level: 'missing', detail: '未装配', fix: 'npx @danceiny/gotry doctor --fix' },
192
+ { label: 'hbcli(酒店实时源)', level: 'missing', detail: '未安装', fix: 'npx @danceiny/gotry doctor --fix' },
193
+ { label: 'FlyAI(飞猪官方检索)', level: 'degraded', detail: '未配 FLYAI_API_KEY', fix: '到 flyai 控制台申请 key' },
194
+ { label: 'dsh-better-sidebar(侧栏工作台)', level: 'missing', detail: '未安装', fix: 'npx @danceiny/gotry doctor --fix' },
195
+ { label: 'dsh-calendar(日历工作窗口)', level: 'degraded', detail: '已挂载未配置 username', fix: 'cordis.patch.yml 覆盖 config' },
196
+ { label: 'dsh-map-tools(地图/路线/POI)', level: 'missing', detail: '未随包解析', fix: '重装 @danceiny/gotry' },
197
+ { label: 'dsh-tool-ask-user(结构化澄清卡)', level: 'missing', detail: '未解析', fix: '重装 @danceiny/gotry' },
198
+ { label: 'LLM key', level: 'ok', detail: '由 dsh 宿主管', fix: undefined },
199
+ ];
200
+ const hbcliDegraded = { label: 'hbcli(酒店实时源)', level: 'degraded', detail: '凭证未配置', fix: 'hbcli auth set-credentials ...' };
201
+ // 12. onboarding 纯函数分类与计划:classifyDoctorGap / buildOnboardingPlan(合成 items,不依赖真机)
202
+ {
203
+ const { classifyDoctorGap, buildOnboardingPlan } = await import(bootstrap);
204
+ assert.equal(classifyDoctorGap(onboardingItems[0]), 'ok');
205
+ assert.equal(classifyDoctorGap(onboardingItems[1]), 'user-action'); // 扩展 = 浏览器商店
206
+ assert.equal(classifyDoctorGap(onboardingItems[2]), 'auto'); // reach = pip 可装
207
+ assert.equal(classifyDoctorGap(onboardingItems[3]), 'auto'); // hbcli missing = npm 可装
208
+ assert.equal(classifyDoctorGap(hbcliDegraded), 'user-action'); // hbcli degraded = 凭证(用户操作)
209
+ assert.equal(classifyDoctorGap(onboardingItems[4]), 'user-action'); // flyai = 上游 key
210
+ assert.equal(classifyDoctorGap(onboardingItems[5]), 'auto'); // sidebar = dsh plugin 可装
211
+ assert.equal(classifyDoctorGap(onboardingItems[6]), 'user-action'); // calendar = profile 配置
212
+ assert.equal(classifyDoctorGap(onboardingItems[7]), 'unavailable'); // map-tools = 重装 gotry
213
+ assert.equal(classifyDoctorGap(onboardingItems[8]), 'unavailable'); // ask-user = 重装 gotry
214
+ assert.equal(classifyDoctorGap(onboardingItems[9]), 'ok'); // LLM key 永远 ok
215
+ // env opt-out(GOTRY_SETUP_*=0)→ unavailable,不冒充 auto;env 注入,不依赖 ambient GOTRY_SETUP_*
216
+ assert.equal(classifyDoctorGap(onboardingItems[2], { env: { GOTRY_SETUP_REACH: '0' } }), 'unavailable', 'reach opt-out → unavailable');
217
+ assert.equal(classifyDoctorGap(onboardingItems[3], { env: { GOTRY_SETUP_HBCLI: '0' } }), 'unavailable', 'hbcli opt-out → unavailable');
218
+ assert.equal(classifyDoctorGap(onboardingItems[5], { env: { GOTRY_SETUP_SIDEBAR: '0' } }), 'unavailable', 'sidebar opt-out → unavailable');
219
+ assert.equal(classifyDoctorGap(onboardingItems[2], { platform: 'win32', env: { GOTRY_SETUP_REACH: '0' } }), 'unavailable', 'opt-out + win32 仍 unavailable');
220
+ const plan = buildOnboardingPlan(onboardingItems);
221
+ assert.equal(plan.promptable, true, '有可自动安装缺项 → promptable');
222
+ assert.equal(plan.auto.length, 3, 'auto = reach + hbcli-missing + sidebar');
223
+ assert.equal(plan.userAction.length, 3, 'user-action = 扩展 + flyai + calendar');
224
+ assert.equal(plan.unavailable.length, 2, 'unavailable = map-tools + ask-user');
225
+ // env opt-out 计划:三项 auto 全 opt-out → promptable=false,auto 空,三项进 unavailable(原 detail,非 win32 不叠平台原因)
226
+ const planOptOut = buildOnboardingPlan(onboardingItems, { env: { GOTRY_SETUP_REACH: '0', GOTRY_SETUP_HBCLI: '0', GOTRY_SETUP_SIDEBAR: '0' } });
227
+ assert.equal(planOptOut.promptable, false, '三项全 opt-out → 无 auto → 不 prompt');
228
+ assert.equal(planOptOut.auto.length, 0);
229
+ const optOutLabels = new Set(planOptOut.unavailable.map((g) => g.label));
230
+ assert.ok(optOutLabels.has('Agent Reach(网页/社媒读取)'), 'reach opt-out 进 unavailable');
231
+ assert.ok(optOutLabels.has('hbcli(酒店实时源)'), 'hbcli opt-out 进 unavailable');
232
+ assert.ok(optOutLabels.has('dsh-better-sidebar(侧栏工作台)'), 'sidebar opt-out 进 unavailable');
233
+ // 全健康 → promptable=false(不弹问)
234
+ const planOk = buildOnboardingPlan(onboardingItems.map((i) => ({ ...i, level: 'ok' })));
235
+ assert.equal(planOk.promptable, false);
236
+ assert.equal(planOk.auto.length, 0);
237
+ assert.equal(planOk.userAction.length, 0);
238
+ assert.equal(planOk.unavailable.length, 0);
239
+ }
240
+ console.log('12. onboarding 分类与计划(classifyDoctorGap/buildOnboardingPlan,合成 items + env opt-out + 全健康)OK');
241
+ // 13. onboardingSkipReason(纯函数):非 TTY / CI / benchmark / opt-out / non-web 全跳过,可 prompt 态返回 null
242
+ {
243
+ const { onboardingSkipReason } = await import(bootstrap);
244
+ assert.equal(onboardingSkipReason({ mode: 'headless' }), 'non-web-mode');
245
+ assert.equal(onboardingSkipReason({ mode: 'web', benchmark: true }), 'benchmark');
246
+ assert.equal(onboardingSkipReason({ mode: 'web', env: { GOTRY_SETUP_SKIP: '1' }, isTTY: true }), 'GOTRY_SETUP_SKIP=1');
247
+ assert.equal(onboardingSkipReason({ mode: 'web', env: { GOTRY_ONBOARDING_SKIP: '1' }, isTTY: true }), 'GOTRY_ONBOARDING_SKIP=1');
248
+ assert.equal(onboardingSkipReason({ mode: 'web', env: { CI: '1' }, isTTY: true }), 'CI');
249
+ assert.equal(onboardingSkipReason({ mode: 'web', env: {}, argv: ['x', '--no-onboarding'], isTTY: true }), '--no-onboarding');
250
+ assert.equal(onboardingSkipReason({ mode: 'web', env: {}, isTTY: false }), 'non-tty');
251
+ assert.equal(onboardingSkipReason({ mode: 'web', env: {}, isTTY: true }), null, '可 prompt');
252
+ // bootstrap 直接调用(mode 缺省)只检 env/argv/isTTY —— 用于 onboarding 子命令自身的防御
253
+ assert.equal(onboardingSkipReason({ env: {}, isTTY: true }), null);
254
+ assert.equal(onboardingSkipReason({ env: {}, isTTY: false }), 'non-tty');
255
+ }
256
+ console.log('13. onboardingSkipReason(非 TTY/CI/benchmark/--no-onboarding/SKIP 跳过 + 可 prompt)OK');
257
+ // 14. runOnboardingFix yes 路径:注入 fakes → auto 全 installed,user-action/unavailable 不动(不跑真安装器)
258
+ {
259
+ const { buildOnboardingPlan, runOnboardingFix } = await import(bootstrap);
260
+ const plan = buildOnboardingPlan(onboardingItems);
261
+ const calls = [];
262
+ const fakeInstallers = {
263
+ hbcli: async () => { calls.push('hbcli'); return { ok: true }; },
264
+ reach: async () => { calls.push('reach'); return { ok: true }; },
265
+ sidebar: async () => { calls.push('sidebar'); return { ok: true }; },
266
+ };
267
+ const fakeRecheck = async () => onboardingItems.map((i) => ({ ...i, level: 'ok' }));
268
+ const results = await runOnboardingFix(plan, { installers: fakeInstallers, recheck: fakeRecheck });
269
+ assert.deepEqual(calls.slice().sort(), ['hbcli', 'reach', 'sidebar'], '三个 auto 安装器各调一次');
270
+ assert.equal(calls.length, 3, '不重复安装,无额外调用');
271
+ const byLabel = new Map(results.map((r) => [r.label, r]));
272
+ assert.equal(byLabel.get('Agent Reach(网页/社媒读取)').status, 'installed');
273
+ assert.equal(byLabel.get('hbcli(酒店实时源)').status, 'installed');
274
+ assert.equal(byLabel.get('dsh-better-sidebar(侧栏工作台)').status, 'installed');
275
+ assert.equal(byLabel.get('GoTry Session Bridge 扩展').status, 'needs-user-action');
276
+ assert.equal(byLabel.get('FlyAI(飞猪官方检索)').status, 'needs-user-action');
277
+ assert.equal(byLabel.get('dsh-calendar(日历工作窗口)').status, 'needs-user-action');
278
+ assert.equal(byLabel.get('dsh-map-tools(地图/路线/POI)').status, 'unavailable');
279
+ assert.equal(byLabel.get('dsh-tool-ask-user(结构化澄清卡)').status, 'unavailable');
280
+ }
281
+ console.log('14. runOnboardingFix yes 路径(注入 fakes → auto=installed,余者不动,不跑真安装器)OK');
282
+ // 15. partial failure:hbcli 安装器失败 → 'unavailable' + 重试命令;reach/sidebar 仍 installed,不挡 web
283
+ {
284
+ const { buildOnboardingPlan, runOnboardingFix } = await import(bootstrap);
285
+ const plan = buildOnboardingPlan(onboardingItems);
286
+ const fakeInstallers = {
287
+ hbcli: async () => ({ ok: false, error: 'npm install timeout' }),
288
+ reach: async () => ({ ok: true }),
289
+ sidebar: async () => ({ ok: true }),
290
+ };
291
+ // 复检:hbcli 仍 missing(安装失败未装上),reach/sidebar 已 ok
292
+ const fakeRecheck = async () => onboardingItems.map((i) => i.label.startsWith('hbcli') ? i : { ...i, level: 'ok' });
293
+ const results = await runOnboardingFix(plan, { installers: fakeInstallers, recheck: fakeRecheck });
294
+ const byLabel = new Map(results.map((r) => [r.label, r]));
295
+ const hbcliRes = byLabel.get('hbcli(酒店实时源)');
296
+ assert.equal(hbcliRes.status, 'unavailable', '安装失败 → unavailable(诚实,不冒充 installed)');
297
+ assert.match(hbcliRes.reason, /npm install timeout/, '失败原因透传');
298
+ assert.equal(hbcliRes.retry, 'npx @danceiny/gotry doctor --fix', '给可重试命令');
299
+ assert.equal(byLabel.get('Agent Reach(网页/社媒读取)').status, 'installed', '部分失败不挡其余');
300
+ assert.equal(byLabel.get('dsh-better-sidebar(侧栏工作台)').status, 'installed');
301
+ }
302
+ console.log('15. partial failure(一项失败 → unavailable+重试命令,不挡其余已装项)OK');
303
+ // 16. idempotent retry:首次修复后复检全健康 → 新计划 promptable=false → 再跑零安装器调用(幂等)
304
+ {
305
+ const { buildOnboardingPlan, runOnboardingFix } = await import(bootstrap);
306
+ const plan1 = buildOnboardingPlan(onboardingItems);
307
+ const calls = [];
308
+ const fakeInstallers = {
309
+ hbcli: async () => { calls.push('hbcli'); return { ok: true }; },
310
+ reach: async () => { calls.push('reach'); return { ok: true }; },
311
+ sidebar: async () => { calls.push('sidebar'); return { ok: true }; },
312
+ };
313
+ const allOk = async () => onboardingItems.map((i) => ({ ...i, level: 'ok' }));
314
+ await runOnboardingFix(plan1, { installers: fakeInstallers, recheck: allOk });
315
+ // 再跑:复检全 ok → 新计划无 auto → 不调任何安装器
316
+ const plan2 = buildOnboardingPlan(await allOk());
317
+ assert.equal(plan2.promptable, false, '全健康 → 不再 prompt');
318
+ assert.equal(plan2.auto.length, 0);
319
+ const callsBefore = calls.length;
320
+ const results2 = await runOnboardingFix(plan2, { installers: fakeInstallers, recheck: allOk });
321
+ assert.equal(calls.length, callsBefore, '已健康项不重装(幂等,零安装器调用)');
322
+ assert.equal(results2.length, 0, '无缺项 → 无结果');
323
+ }
324
+ console.log('16. idempotent retry(已健康项不再重装,安装器零调用)OK');
325
+ // 17. promptOnboarding yes/no/默认-no(注入流,不读真 stdin,不开浏览器)
326
+ {
327
+ const { buildOnboardingPlan, promptOnboarding } = await import(bootstrap);
328
+ const { PassThrough } = await import('node:stream');
329
+ const plan = buildOnboardingPlan(onboardingItems);
330
+ const sink = { write: () => true };
331
+ // yes
332
+ const yesIn = new PassThrough();
333
+ const yesOut = [];
334
+ const yesP = promptOnboarding(plan, { input: yesIn, output: { write: (s) => { yesOut.push(s); return true; } } });
335
+ yesIn.end('y\n');
336
+ assert.equal(await yesP, 'yes');
337
+ assert.ok(yesOut.join('').includes('y/N'), 'prompt 文案含 y/N');
338
+ assert.ok(yesOut.join('').includes('Agent Reach'), 'prompt 列出可自动配置项');
339
+ // no
340
+ const noIn = new PassThrough();
341
+ const noP = promptOnboarding(plan, { input: noIn, output: sink });
342
+ noIn.end('n\n');
343
+ assert.equal(await noP, 'no');
344
+ // 默认 no(空行)
345
+ const emptyIn = new PassThrough();
346
+ const emptyP = promptOnboarding(plan, { input: emptyIn, output: sink });
347
+ emptyIn.end('\n');
348
+ assert.equal(await emptyP, 'no', '空行默认 = no(立即继续 web)');
349
+ }
350
+ console.log('17. promptOnboarding yes/no/默认-no(注入流,不读真 stdin)OK');
351
+ // 18. onboarding CLI 跳过:非 TTY / CI / GOTRY_SETUP_SKIP / GOTRY_ONBOARDING_SKIP → 零 prompt 零安装,恒 exit 0
352
+ // execFileSync 的 stdin 是 pipe(非 TTY),自然走 non-tty 跳过;CI/SKIP env 各自短路。
353
+ {
354
+ const isolated = mkdtempSync(join(tmpdir(), 'gotry-onboard-skip-'));
355
+ const r1 = runBootstrap(['onboarding'], { HOME: isolated });
356
+ assert.equal(r1.code, 0, `非 TTY onboarding 应 exit 0\n${r1.out}`);
357
+ assert.ok(!r1.out.includes('y/N'), '非 TTY 不应 prompt');
358
+ assert.ok(!r1.out.includes('开始自动配置'), '非 TTY 不应安装');
359
+ const r2 = runBootstrap(['onboarding'], { HOME: isolated, CI: '1' });
360
+ assert.equal(r2.code, 0);
361
+ assert.ok(!r2.out.includes('y/N'));
362
+ const r3 = runBootstrap(['onboarding'], { HOME: isolated, GOTRY_SETUP_SKIP: '1' });
363
+ assert.equal(r3.code, 0);
364
+ assert.ok(!r3.out.includes('y/N'));
365
+ const r4 = runBootstrap(['onboarding'], { HOME: isolated, GOTRY_ONBOARDING_SKIP: '1' });
366
+ assert.equal(r4.code, 0);
367
+ assert.ok(!r4.out.includes('y/N'));
368
+ }
369
+ console.log('18. onboarding CLI 跳过(非 TTY / CI / GOTRY_SETUP_SKIP / GOTRY_ONBOARDING_SKIP,零 prompt 零安装)OK');
370
+ // 19. onboarding --scan:临时包 fixture + 隔离 HOME + 受控 PATH → 确定性只读计划(零 prompt 零安装,不写 gotry-state)
371
+ // PATH 不能直接置空(execFileSync 用 child env.PATH 解析 'node',空则 ENOENT)——
372
+ // 故前置 node bin 目录(execPath dirname)再接一个空目录:node 可解析、hbcli/python3 不可达;
373
+ // 隔离 HOME 排除真机 extension/sidebar/calendar。reach 检测键在 repoRoot/.venv,而 bootstrap 的
374
+ // repoRoot 取自自身文件位置——HOME 隔离不了它:开发者 worktree 常已装 agent-reach(.venv 就位
375
+ // → reach=ok,不进 auto 桶),「fresh checkout 无 .venv」假设不可依赖(2026-09-09 实测创始机
376
+ // 即中招)。故照 §21 的临时安装包 fixture 模式:bin/ 拷贝 + 随包 vendor 负载(map-tools/
377
+ // ask-user)软链进 fixture,repoRoot 指 fixture → .venv 恒缺失(reach missing → auto),
378
+ // payload 仍「随包就位」(非 unavailable),任意机器确定性。
379
+ {
380
+ const isolated = mkdtempSync(join(tmpdir(), 'gotry-onboard-scan-'));
381
+ const emptyPath = mkdtempSync(join(tmpdir(), 'gotry-empty-path-'));
382
+ const fixture = mkdtempSync(join(tmpdir(), 'gotry-onboard-pkg-'));
383
+ mkdirSync(join(fixture, 'bin'), { recursive: true });
384
+ copyFileSync(bootstrap, join(fixture, 'bin', 'gotry-bootstrap.js'));
385
+ copyFileSync(join(repoRoot, 'package.json'), join(fixture, 'package.json'));
386
+ const vendorDir = join(fixture, 'ts', 'dsh-runtime', 'vendor');
387
+ mkdirSync(vendorDir, { recursive: true });
388
+ symlinkSync(join(repoRoot, 'ts/dsh-runtime/vendor/dsh-map-tools'), join(vendorDir, 'dsh-map-tools'));
389
+ symlinkSync(join(repoRoot, 'ts/dsh-runtime/vendor/deepseek-ai-dsh-tool-ask-user'), join(vendorDir, 'deepseek-ai-dsh-tool-ask-user'));
390
+ const safePath = `${dirname(process.execPath)}${delimiter}${emptyPath}`;
391
+ const r = runBootstrap(['onboarding', '--scan'], {
392
+ HOME: isolated,
393
+ PATH: safePath,
394
+ FLYAI_API_KEY: '',
395
+ GOTRY_SETUP_HBCLI: '',
396
+ GOTRY_SETUP_REACH: '',
397
+ GOTRY_SETUP_SIDEBAR: '',
398
+ }, join(fixture, 'bin', 'gotry-bootstrap.js'));
399
+ assert.equal(r.code, 0, `--scan 应 exit 0\n${r.out}`);
400
+ assert.ok(!r.out.includes('y/N'), '--scan 不应 prompt');
401
+ assert.ok(!r.out.includes('开始自动配置'), '--scan 不应安装');
402
+ const plan = JSON.parse(r.out.trim().split('\n').pop());
403
+ assert.equal(plan.promptable, true);
404
+ const autoLabels = plan.auto.map((g) => g.label);
405
+ assert.ok(autoLabels.includes('dsh-better-sidebar(侧栏工作台)'), 'sidebar missing(隔离 HOME)→ auto');
406
+ assert.ok(autoLabels.includes('hbcli(酒店实时源)'), 'hbcli missing(空 PATH)→ auto');
407
+ assert.ok(autoLabels.includes('Agent Reach(网页/社媒读取)'), 'reach missing(fixture 无 .venv)→ auto');
408
+ const userActionLabels = plan.userAction.map((g) => g.label);
409
+ assert.ok(userActionLabels.includes('GoTry Session Bridge 扩展'), '扩展 missing(隔离 HOME)→ user-action(浏览器商店)');
410
+ assert.ok(userActionLabels.includes('FlyAI(飞猪官方检索)'), 'flyai 无 key → user-action');
411
+ assert.ok(!plan.unavailable.some((g) => g.label.startsWith('dsh-map-tools')), 'map-tools 随包就位 → 非 unavailable');
412
+ assert.ok(!plan.unavailable.some((g) => g.label.startsWith('dsh-tool-ask-user')), 'ask-user 随包就位 → 非 unavailable');
413
+ }
414
+ console.log('19. onboarding --scan(临时包 fixture + 隔离 HOME + 受控 PATH,确定性只读计划,零 prompt 零安装)OK');
415
+ // --- issue #258 E2E launch-boundary(orchestrateWebLaunch × 注入依赖,跨 inner→onboarding→web 边界)---
416
+ // 20. 生产编排缝覆盖:orchestrateWebLaunch 是 inner 启动 web 的真实编排(跳过判定 → onboarding →
417
+ // 后台摘要 → dsh web)。进程内用真实 runOnboarding(注入 scan/installers/stdin/isTTY/platform/env)
418
+ // + 假 launchWeb 标记,证明 yes/no/非 TTY/部分失败/摘要抑制/win32 reported 六条路径都跨过边界且
419
+ // 到达 web 启动标记。隔离:合成 items + 假安装器 + 假复检 + 注入流,永不跑真安装器/开浏览器/spawn 真 dsh。
420
+ // 失败模式:任一路径未到达 launchWeb = 编排回归(用户卡在 onboarding 进不到 web)。
421
+ {
422
+ const { orchestrateWebLaunch, runOnboarding } = await import(bootstrap);
423
+ const { PassThrough } = await import('node:stream');
424
+ const sink = { write: () => true };
425
+ const noSummary = () => { };
426
+ // 共享 fakes:okInstallers 记录调用顺序;allOkRecheck 模拟安装后复检全绿。
427
+ const okInstallers = (calls = []) => ({
428
+ hbcli: async () => { calls.push('hbcli'); return { ok: true }; },
429
+ reach: async () => { calls.push('reach'); return { ok: true }; },
430
+ sidebar: async () => { calls.push('sidebar'); return { ok: true }; },
431
+ });
432
+ const allOkRecheck = async () => onboardingItems.map((i) => ({ ...i, level: 'ok' }));
433
+ // launchWeb 标记:被调到即证明编排到达 web 启动边界(不 spawn 真 dsh)
434
+ const makeLaunchWeb = () => { let n = 0; return { fn: async () => { n += 1; return { launched: true }; }, count: () => n }; };
435
+ // 真实 runOnboarding(bootstrap)包成 orchestrateWebLaunch 依赖:注入合成 items/假安装器/假复检/流,
436
+ // 强制 isTTY=true + 干净 env → 跨过真实 onboarding 逻辑。platform 默认 darwin。
437
+ const wire = (o) => async () => runOnboarding([], {
438
+ scan: async () => onboardingItems,
439
+ isTTY: true, env: o.env ?? {}, argv: ['node', 'gotry-inner.js', 'web'],
440
+ input: o.input, output: sink,
441
+ installers: o.installers, recheck: o.recheck,
442
+ platform: o.platform ?? 'darwin',
443
+ });
444
+ // 20a. interactive yes + 假成功安装器 → 三项 installed,launchWeb 到达 1 次
445
+ {
446
+ const lw = makeLaunchWeb();
447
+ const yesIn = new PassThrough();
448
+ yesIn.end('y\n');
449
+ const calls = [];
450
+ const r = await orchestrateWebLaunch({
451
+ onboardingSkip: null,
452
+ runOnboarding: wire({ input: yesIn, installers: okInstallers(calls), recheck: allOkRecheck }),
453
+ runSummary: noSummary, launchWeb: lw.fn,
454
+ });
455
+ assert.equal(r.launched, true, 'yes 路径到达 web 启动标记');
456
+ assert.equal(lw.count(), 1, 'launchWeb 恰好调一次');
457
+ assert.equal(r.onboardingPrompted, true);
458
+ assert.equal(r.onboardingResult.answered, 'yes');
459
+ assert.deepEqual(calls.slice().sort(), ['hbcli', 'reach', 'sidebar'], '三个假安装器各调一次');
460
+ const installed = r.onboardingResult.results.filter((x) => x.status === 'installed');
461
+ assert.equal(installed.length, 3, '三项 auto 缺项 installed');
462
+ assert.ok(r.onboardingResult.results.every((x) => ['installed', 'needs-user-action', 'unavailable'].includes(x.status)), '三态结果齐全');
463
+ }
464
+ console.log('20a. E2E yes + 假成功安装器 → 三项 installed,到达 web 启动标记 OK');
465
+ // 20b. interactive refusal → 零安装器调用(零安装副作用),launchWeb 仍到达
466
+ {
467
+ const lw = makeLaunchWeb();
468
+ const noIn = new PassThrough();
469
+ noIn.end('n\n');
470
+ const calls = [];
471
+ const r = await orchestrateWebLaunch({
472
+ onboardingSkip: null,
473
+ runOnboarding: wire({ input: noIn, installers: okInstallers(calls) }),
474
+ runSummary: noSummary, launchWeb: lw.fn,
475
+ });
476
+ assert.equal(r.launched, true, 'no 路径仍到达 web 启动标记');
477
+ assert.equal(lw.count(), 1);
478
+ assert.equal(r.onboardingPrompted, true);
479
+ assert.equal(r.onboardingResult.answered, 'no');
480
+ assert.equal(calls.length, 0, '拒绝 → 零安装器调用,零安装副作用');
481
+ }
482
+ console.log('20b. E2E interactive refusal → 零安装副作用,仍到达 web OK');
483
+ // 20c. non-TTY → onboardingSkip='non-tty' → runOnboarding 不被调(零 prompt 零安装),launchWeb 到达
484
+ {
485
+ const lw = makeLaunchWeb();
486
+ let onboardingCalled = 0;
487
+ const r = await orchestrateWebLaunch({
488
+ onboardingSkip: 'non-tty',
489
+ runOnboarding: async () => { onboardingCalled += 1; return { prompted: false }; },
490
+ runSummary: noSummary, launchWeb: lw.fn,
491
+ });
492
+ assert.equal(r.launched, true, '非 TTY 仍到达 web 启动标记');
493
+ assert.equal(lw.count(), 1);
494
+ assert.equal(onboardingCalled, 0, '非 TTY → runOnboarding 不被调(零 prompt 零安装)');
495
+ assert.equal(r.onboardingPrompted, false);
496
+ }
497
+ console.log('20c. E2E non-TTY → 零 prompt 零安装,仍到达 web OK');
498
+ // 20d. partial installer failure(hbcli 失败)→ 仍到达 web;hbcli unavailable+重试,reach/sidebar installed
499
+ {
500
+ const lw = makeLaunchWeb();
501
+ const yesIn = new PassThrough();
502
+ yesIn.end('y\n');
503
+ const installers = {
504
+ hbcli: async () => ({ ok: false, error: 'npm install timeout' }),
505
+ reach: async () => ({ ok: true }),
506
+ sidebar: async () => ({ ok: true }),
507
+ };
508
+ const recheck = async () => onboardingItems.map((i) => i.label.startsWith('hbcli') ? i : { ...i, level: 'ok' });
509
+ const r = await orchestrateWebLaunch({
510
+ onboardingSkip: null,
511
+ runOnboarding: wire({ input: yesIn, installers, recheck }),
512
+ runSummary: noSummary, launchWeb: lw.fn,
513
+ });
514
+ assert.equal(r.launched, true, '部分失败仍到达 web 启动标记');
515
+ assert.equal(lw.count(), 1);
516
+ const byLabel = new Map(r.onboardingResult.results.map((x) => [x.label, x]));
517
+ assert.equal(byLabel.get('hbcli(酒店实时源)').status, 'unavailable', 'hbcli 安装失败 → unavailable(诚实)');
518
+ assert.match(byLabel.get('hbcli(酒店实时源)').reason, /npm install timeout/, '失败原因透传');
519
+ assert.equal(byLabel.get('hbcli(酒店实时源)').retry, 'npx @danceiny/gotry doctor --fix', '给重试命令');
520
+ assert.equal(byLabel.get('Agent Reach(网页/社媒读取)').status, 'installed', '部分失败不挡其余');
521
+ assert.equal(byLabel.get('dsh-better-sidebar(侧栏工作台)').status, 'installed');
522
+ }
523
+ console.log('20d. E2E partial failure → hbcli unavailable+重试,reach/sidebar installed,仍到达 web OK');
524
+ // 20e. 摘要抑制:onboarding prompt 过或 reported 过 → runSummary 不调;两者皆无 → 保留
525
+ // 失败模式:prompted/reported 后仍跑摘要 = 重复打扰(分类计划 + 一行摘要叠显)。
526
+ {
527
+ // prompted(yes 路径)→ 抑制
528
+ const lw1 = makeLaunchWeb();
529
+ let sum1 = 0;
530
+ const yesIn = new PassThrough();
531
+ yesIn.end('y\n');
532
+ await orchestrateWebLaunch({
533
+ onboardingSkip: null,
534
+ runOnboarding: wire({ input: yesIn, installers: okInstallers(), recheck: allOkRecheck }),
535
+ runSummary: () => { sum1 += 1; }, launchWeb: lw1.fn,
536
+ });
537
+ assert.equal(sum1, 0, 'prompted → 后台摘要抑制');
538
+ // reported(已渲染分类计划,如 win32 无 auto)→ 抑制
539
+ const lw2 = makeLaunchWeb();
540
+ let sum2 = 0;
541
+ await orchestrateWebLaunch({
542
+ onboardingSkip: null,
543
+ runOnboarding: async () => ({ prompted: false, reported: true, plan: { promptable: false, auto: [], userAction: [], unavailable: [] } }),
544
+ runSummary: () => { sum2 += 1; }, launchWeb: lw2.fn,
545
+ });
546
+ assert.equal(sum2, 0, 'reported → 后台摘要抑制(不重复分类计划)');
547
+ // 皆无(全健康,prompted=false 无 reported)→ 保留(全健康时摘要本身静默,此处只验调用)
548
+ const lw3 = makeLaunchWeb();
549
+ let sum3 = 0;
550
+ await orchestrateWebLaunch({
551
+ onboardingSkip: null,
552
+ runOnboarding: async () => ({ prompted: false, plan: { promptable: false, auto: [], userAction: [], unavailable: [] } }),
553
+ runSummary: () => { sum3 += 1; }, launchWeb: lw3.fn,
554
+ });
555
+ assert.equal(sum3, 1, '未 prompt 未 reported → 后台摘要保留');
556
+ }
557
+ console.log('20e. E2E 摘要抑制(prompted 或 reported → 不重复;皆无 → 保留)OK');
558
+ // 20f. win32 interactive 无 auto 缺项 → 渲染分类计划(unavailable + user-action 带具体 Windows 原因),
559
+ // 不 prompt 不安装,reported:true。断言实际可见性:输出含分类行与平台原因,不含 y/N。
560
+ // 失败模式:win32 缺项只进内部 plan 不渲染 = 用户看不到分类与原因(分类可见性回归)。
561
+ {
562
+ const lw = makeLaunchWeb();
563
+ const out = [];
564
+ const output = { write: (s) => { out.push(s); return true; } };
565
+ const calls = [];
566
+ const r = await orchestrateWebLaunch({
567
+ onboardingSkip: null,
568
+ runOnboarding: async () => runOnboarding([], {
569
+ scan: async () => onboardingItems,
570
+ isTTY: true, env: {}, argv: ['node', 'gotry-inner.js', 'web'],
571
+ input: new PassThrough(), output,
572
+ installers: okInstallers(calls), platform: 'win32',
573
+ }),
574
+ runSummary: noSummary, launchWeb: lw.fn,
575
+ });
576
+ assert.equal(lw.count(), 1, 'win32 reported 路径仍到达 web 启动标记');
577
+ assert.equal(r.onboardingResult.prompted, false, 'win32 无 auto → 不 prompt');
578
+ assert.equal(r.onboardingResult.reported, true, 'win32 有 reportable 缺项 → reported:true');
579
+ assert.equal(calls.length, 0, 'win32 reported → 零安装器调用');
580
+ const visible = out.join('');
581
+ assert.ok(!visible.includes('y/N'), 'reported 路径不 prompt(无 y/N)');
582
+ assert.ok(!visible.includes('开始自动配置'), 'reported 路径不安装');
583
+ // unavailable 项带 Windows 平台具体原因(诚实,不冒充 auto / 不给无效的 doctor --fix 指引)
584
+ assert.ok(visible.includes('hbcli(酒店实时源)'), '渲染 hbcli 不可用行');
585
+ assert.match(visible, /Windows[^\n]*hbcli/, 'hbcli 行带 Windows 原因');
586
+ assert.ok(visible.includes('Agent Reach(网页/社媒读取)'), '渲染 reach 不可用行');
587
+ assert.match(visible, /Windows[^\n]*agent-reach/, 'reach 行带 Windows 原因');
588
+ assert.ok(visible.includes('dsh-better-sidebar(侧栏工作台)'), '渲染 sidebar 不可用行');
589
+ assert.match(visible, /Windows[^\n]*dsh-better-sidebar/, 'sidebar 行带 Windows 原因');
590
+ // user-action 项平台无关,仍渲染(扩展商店/flyai key/calendar)
591
+ assert.ok(visible.includes('GoTry Session Bridge 扩展'), '渲染扩展 user-action 行');
592
+ assert.ok(visible.includes('FlyAI(飞猪官方检索)'), '渲染 flyai user-action 行');
593
+ assert.ok(visible.includes('dsh-calendar(日历工作窗口)'), '渲染 calendar user-action 行');
594
+ }
595
+ console.log('20f. E2E win32 reported → 渲染分类计划(带 Windows 原因)+ reported:true + 零安装,仍到达 web OK');
596
+ }
597
+ console.log('20. E2E launch-boundary(orchestrateWebLaunch × yes/no/non-TTY/partial-failure/摘要抑制/win32 reported,假 web 启动标记)OK');
598
+ // 21. spawned inner in a temporary installed-package fixture(真实跨进程 inner→bootstrap onboarding→dsh web 边界)。
599
+ // 不变量:全隔离在 temp 内,永不碰共享 ts/dsh-runtime/gotry-state 或真 dsh。确定性临时安装包
600
+ // (无 .git → installed-package 模式):复制生产 bin/gotry-{inner,bootstrap,runtime-resolution}.js +
601
+ // cordis.gotry-patch.yml(精确提交文件,不改生产代码);造 dist/src/index.js 占位;造假 @deepseek-ai/dsh
602
+ // (lib/bin.js 写 web-launch 标记后逗留 1.5s 再 exit——给被误启动的 detached doctor --summary 在 stderr
603
+ // 露面窗口,使「摘要抑制」断言可信)。fixture-local CJS NODE_OPTIONS preload 让被 spawn 的 inner 与
604
+ // bootstrap 子进程都认为 stdin 是 TTY,使 onboarding 走可 prompt 的生产路径(stdin 仍是 pipe,可喂确定性答案)。
605
+ //
606
+ // 21a(TTY eligible,喂 "n"):先观察到真实 prompt 再喂答案 → 断言 prompt 恰好一次、无安装/结果行、
607
+ // 假 dsh 恰好一次收到 web+--no-open、后台摘要被抑制、TMPDIR 无 gotry-onboarding-* 残留。
608
+ // 21b(non-TTY,无 preload):stdin pipe → onboarding 跳过 → 断言零 prompt、假 dsh 恰好一次。
609
+ // 21c(POSIX 信号,仅非 win32):不喂答案,onboarding 阻塞等输入;先观察到 prompt,向父进程单独发
610
+ // SIGTERM,等终止 → 断言 TMPDIR 无 gotry-onboarding-* 与 gotry-cordis-* 残留、进程组无残留子进程、
611
+ // 假 dsh 未被调用。超时 = FAIL(非 skip)。win32 仅跳过信号子例。
612
+ // 21d(生产 onboarding 超时上界):TTY eligible + GOTRY_ONBOARDING_CHILD_TIMEOUT_MS=1000 + 不喂输入 →
613
+ // onboarding 挂起;断言 1s 超时后 inner 继续 web(假 dsh 启动)、无 gotry-onboarding-* 残留、exit 0。
614
+ // 21f(POSIX yes 安装期间信号):喂 "y" 进入真实 setupHbcli → fake bash installer 睡眠;只向父进程
615
+ // SIGTERM,断言 inner/onboarding/bootstrap/installer 子树 bounded reap,假 dsh 未启动,无 temp/process 残留。
616
+ // 21g(POSIX yes 安装期间 onboarding timeout):喂 "y" 进入 stubborn fake installer,等 child ready 后由 5s timeout 触发,
617
+ // 断言 outer grace 覆盖 bootstrap installer TERM+SIGKILL budget,installer 子树死净且继续 web。
618
+ function buildPkgFixture(tmpRoot) {
619
+ const pkgRoot = join(tmpRoot, 'pkg');
620
+ const pkgBin = join(pkgRoot, 'bin');
621
+ const pkgDist = join(pkgRoot, 'dist', 'src');
622
+ const dshDir = join(pkgRoot, 'node_modules', '@deepseek-ai', 'dsh');
623
+ mkdirSync(pkgBin, { recursive: true });
624
+ mkdirSync(pkgDist, { recursive: true });
625
+ mkdirSync(join(dshDir, 'lib'), { recursive: true });
626
+ writeFileSync(join(pkgRoot, 'package.json'), `${JSON.stringify({ name: 'gotry', version: '0.0.1-test', type: 'module' })}\n`);
627
+ copyFileSync(join(repoRoot, 'bin', 'gotry-inner.js'), join(pkgBin, 'gotry-inner.js'));
628
+ copyFileSync(join(repoRoot, 'bin', 'gotry-bootstrap.js'), join(pkgBin, 'gotry-bootstrap.js'));
629
+ copyFileSync(join(repoRoot, 'bin', 'gotry-runtime-resolution.js'), join(pkgBin, 'gotry-runtime-resolution.js'));
630
+ copyFileSync(join(repoRoot, 'bin', 'gotry-process-liveness.js'), join(pkgBin, 'gotry-process-liveness.js'));
631
+ copyFileSync(join(repoRoot, 'cordis.gotry-patch.yml'), join(pkgRoot, 'cordis.gotry-patch.yml'));
632
+ writeFileSync(join(pkgDist, 'index.js'), 'export {}\n');
633
+ writeFileSync(join(dshDir, 'package.json'), `${JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.1.5-rc.1', type: 'module' })}\n`);
634
+ const markerPath = join(tmpRoot, 'dsh-web-launch.log');
635
+ // 假 dsh:写标记后逗留 1.5s 再 exit——若 detached doctor --summary 被误启动,会在此窗口向 stderr(继承)露面。
636
+ writeFileSync(join(dshDir, 'lib', 'bin.js'), `import { appendFileSync } from 'node:fs'
637
+ import { setTimeout as sleep } from 'node:timers/promises'
638
+ const args = process.argv.slice(2)
639
+ appendFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ args }) + '\\n')
640
+ console.log('[fake-dsh] web launch', JSON.stringify(args))
641
+ await sleep(Number.parseInt(process.env.FAKE_DSH_SLEEP_MS || '1500', 10))
642
+ process.exit(0)
643
+ `);
644
+ // fixture-local CJS preload:让 inner 与 bootstrap 子进程认为 stdin 是 TTY,使 onboarding 走可 prompt 路径。
645
+ // 仅作用于本临时 fixture,不复制/修改生产代码。
646
+ const preloadPath = join(pkgRoot, 'fixture-tty-preload.cjs');
647
+ writeFileSync(preloadPath, `'use strict'
648
+ // fixture-only: make spawned children think stdin is a TTY so onboarding is eligible.
649
+ try { Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) } catch (e) {}
650
+ `);
651
+ const cwdDir = join(tmpRoot, 'cwd');
652
+ const homeDir = join(tmpRoot, 'home');
653
+ const tmpDir = join(tmpRoot, 'tmp');
654
+ const emptyDir = join(tmpRoot, 'empty-path');
655
+ mkdirSync(cwdDir, { recursive: true });
656
+ mkdirSync(homeDir, { recursive: true });
657
+ mkdirSync(tmpDir, { recursive: true });
658
+ mkdirSync(emptyDir, { recursive: true });
659
+ const safePath = `${dirname(process.execPath)}${delimiter}${emptyDir}`;
660
+ return { pkgRoot, pkgBin, markerPath, preloadPath, cwdDir, homeDir, tmpDir, safePath };
661
+ }
662
+ function addSleepingInstallerFixture(tmpRoot, fixture) {
663
+ const fakeBin = join(tmpRoot, 'fake-installer-bin');
664
+ const installerMarkerPath = join(tmpRoot, 'sleeping-installer.log');
665
+ const stubbornScriptPath = join(tmpRoot, 'stubborn-installer-child.js');
666
+ mkdirSync(fakeBin, { recursive: true });
667
+ writeFileSync(stubbornScriptPath, `const { appendFileSync } = require('node:fs')
668
+ const marker = process.env.GOTRY_TEST_INSTALLER_MARKER
669
+ process.on('SIGTERM', () => appendFileSync(marker, 'child-term-ignored ' + process.pid + '\\n'))
670
+ process.on('SIGINT', () => appendFileSync(marker, 'child-int-ignored ' + process.pid + '\\n'))
671
+ appendFileSync(marker, 'stubborn-child-ready ' + process.pid + '\\n')
672
+ setInterval(() => {}, 1000)
673
+ `);
674
+ const fakeBash = join(fakeBin, 'bash');
675
+ writeFileSync(fakeBash, `#!/bin/sh
676
+ echo "bash-start $$" >> "$GOTRY_TEST_INSTALLER_MARKER"
677
+ trap 'echo "bash-term-ignored $$" >> "$GOTRY_TEST_INSTALLER_MARKER"' TERM INT
678
+ "${process.execPath}" "${stubbornScriptPath}" &
679
+ sleep_pid=$!
680
+ echo "sleep-pid $sleep_pid" >> "$GOTRY_TEST_INSTALLER_MARKER"
681
+ while kill -0 "$sleep_pid" 2>/dev/null; do
682
+ wait "$sleep_pid" 2>/dev/null || true
683
+ done
684
+ echo "bash-finished $$" >> "$GOTRY_TEST_INSTALLER_MARKER"
685
+ `);
686
+ chmodSync(fakeBash, 0o755);
687
+ return { path: `${fakeBin}${delimiter}${fixture.safePath}`, installerMarkerPath };
688
+ }
689
+ const closedFixtures = new WeakSet();
690
+ function spawnInner(fixture, opts) {
691
+ // NODE_OPTIONS:TTY 路径挂 fixture-local preload;非 TTY 路径清空(移除 tsx loader,temp 包无 tsx 会崩)。
692
+ const nodeOptions = opts.tty ? `--require ${fixture.preloadPath}` : '';
693
+ const child = spawn(process.execPath, [join(fixture.pkgBin, 'gotry-inner.js'), 'web', '--no-open'], {
694
+ env: {
695
+ ...process.env,
696
+ HOME: fixture.homeDir,
697
+ TMPDIR: fixture.tmpDir,
698
+ PATH: fixture.safePath,
699
+ NODE_OPTIONS: nodeOptions,
700
+ CI: '',
701
+ GOTRY_SETUP_SKIP: '',
702
+ GOTRY_ONBOARDING_SKIP: '',
703
+ GOTRY_DEBUG: '',
704
+ ...(opts.env ?? {}),
705
+ },
706
+ cwd: fixture.cwdDir,
707
+ stdio: ['pipe', 'pipe', 'pipe'],
708
+ detached: true,
709
+ });
710
+ child.once('close', () => closedFixtures.add(child));
711
+ return child;
712
+ }
713
+ function collectOutput(child) {
714
+ const stdoutRef = { s: '' };
715
+ let stderr = '';
716
+ child.stdout.on('data', (c) => { stdoutRef.s += c.toString('utf8'); });
717
+ child.stderr.on('data', (c) => { stderr += c.toString('utf8'); });
718
+ return { stdoutRef, getStderr: () => stderr };
719
+ }
720
+ function waitForChildCloseEvent(child, ms) {
721
+ if (closedFixtures.has(child))
722
+ return Promise.resolve(true);
723
+ return new Promise((resolve) => {
724
+ let done = false;
725
+ const cleanup = (value) => {
726
+ if (done)
727
+ return;
728
+ done = true;
729
+ clearTimeout(t);
730
+ child.off('close', onClose);
731
+ child.off('error', onError);
732
+ resolve(value);
733
+ };
734
+ const onClose = () => cleanup(true);
735
+ const onError = () => cleanup(true);
736
+ const t = setTimeout(() => cleanup(false), ms);
737
+ child.once('close', onClose);
738
+ child.once('error', onError);
739
+ });
740
+ }
741
+ function childClosedMessage(label, stdoutRef, expected, code, signal) {
742
+ return new Error(`${label}: 子进程在观察到 ${expected} 前退出(code=${code},signal=${signal})\nstdout:\n${stdoutRef.s.slice(-800)}`);
743
+ }
744
+ const fixtureReaps = new WeakMap();
745
+ function rejectAfterFixtureReap(child, reject, error) {
746
+ void reapFixture(child)
747
+ .catch(() => undefined)
748
+ .then(() => reject(error));
749
+ }
750
+ // 等待 stdout 出现真实 onboarding prompt(证明 bootstrap 真的跑了 prompt 路径);超时 FAIL(非 skip)。
751
+ function waitForPrompt(child, stdoutRef, label, ms = 30_000) {
752
+ return new Promise((resolve, reject) => {
753
+ let done = false;
754
+ const cleanup = () => {
755
+ if (done)
756
+ return false;
757
+ done = true;
758
+ clearTimeout(t);
759
+ child.stdout.off('data', onData);
760
+ child.off('error', onError);
761
+ child.off('close', onClose);
762
+ return true;
763
+ };
764
+ const onError = (error) => {
765
+ if (cleanup())
766
+ rejectAfterFixtureReap(child, reject, error);
767
+ };
768
+ const onClose = (code, signal) => {
769
+ if (cleanup())
770
+ rejectAfterFixtureReap(child, reject, childClosedMessage(label, stdoutRef, 'onboarding prompt', code, signal));
771
+ };
772
+ const onData = () => {
773
+ if (stdoutRef.s.includes('现在自动配置可安装项吗') && cleanup())
774
+ resolve();
775
+ };
776
+ const t = setTimeout(() => {
777
+ if (cleanup())
778
+ rejectAfterFixtureReap(child, reject, new Error(`${label}: 未在 ${ms}ms 内观察到 onboarding prompt\nstdout:\n${stdoutRef.s.slice(-800)}`));
779
+ }, ms);
780
+ child.stdout.on('data', onData);
781
+ child.once('error', onError);
782
+ child.once('close', onClose);
783
+ onData();
784
+ });
785
+ }
786
+ function waitForStdout(child, stdoutRef, needle, label, ms = 30_000) {
787
+ return new Promise((resolve, reject) => {
788
+ let done = false;
789
+ const cleanup = () => {
790
+ if (done)
791
+ return false;
792
+ done = true;
793
+ clearTimeout(t);
794
+ child.stdout.off('data', onData);
795
+ child.off('error', onError);
796
+ child.off('close', onClose);
797
+ return true;
798
+ };
799
+ const onError = (error) => {
800
+ if (cleanup())
801
+ rejectAfterFixtureReap(child, reject, error);
802
+ };
803
+ const onClose = (code, signal) => {
804
+ if (cleanup())
805
+ rejectAfterFixtureReap(child, reject, childClosedMessage(label, stdoutRef, needle, code, signal));
806
+ };
807
+ const onData = () => {
808
+ if (stdoutRef.s.includes(needle) && cleanup())
809
+ resolve();
810
+ };
811
+ const t = setTimeout(() => {
812
+ if (cleanup())
813
+ rejectAfterFixtureReap(child, reject, new Error(`${label}: 未在 ${ms}ms 内观察到 ${needle}\nstdout:\n${stdoutRef.s.slice(-800)}`));
814
+ }, ms);
815
+ child.stdout.on('data', onData);
816
+ child.once('error', onError);
817
+ child.once('close', onClose);
818
+ onData();
819
+ });
820
+ }
821
+ function waitForFileText(child, filePath, needle, stdoutRef, label, ms = 30_000) {
822
+ return new Promise((resolve, reject) => {
823
+ let done = false;
824
+ const cleanup = () => {
825
+ if (done)
826
+ return false;
827
+ done = true;
828
+ clearTimeout(t);
829
+ clearInterval(interval);
830
+ child.off('error', onError);
831
+ child.off('close', onClose);
832
+ return true;
833
+ };
834
+ const current = () => {
835
+ try {
836
+ return readFileSync(filePath, 'utf-8');
837
+ }
838
+ catch {
839
+ return '';
840
+ }
841
+ };
842
+ const onError = (error) => {
843
+ if (cleanup())
844
+ rejectAfterFixtureReap(child, reject, error);
845
+ };
846
+ const onClose = (code, signal) => {
847
+ if (cleanup())
848
+ rejectAfterFixtureReap(child, reject, new Error(`${label}: 子进程在观察到 ${needle} 前退出(code=${code},signal=${signal})\ninstaller-log:\n${current().slice(-800)}\nstdout:\n${stdoutRef.s.slice(-800)}`));
849
+ };
850
+ const poll = () => {
851
+ if (current().includes(needle) && cleanup())
852
+ resolve();
853
+ };
854
+ const interval = setInterval(poll, 50);
855
+ const t = setTimeout(() => {
856
+ if (cleanup())
857
+ rejectAfterFixtureReap(child, reject, new Error(`${label}: 未在 ${ms}ms 内观察到 ${needle}\ninstaller-log:\n${current().slice(-800)}\nstdout:\n${stdoutRef.s.slice(-800)}`));
858
+ }, ms);
859
+ child.once('error', onError);
860
+ child.once('close', onClose);
861
+ poll();
862
+ });
863
+ }
864
+ function pidAlive(pid) {
865
+ try {
866
+ process.kill(pid, 0);
867
+ return true;
868
+ }
869
+ catch {
870
+ return false;
871
+ }
872
+ }
873
+ function processGroupId(pid) {
874
+ if (process.platform === 'win32')
875
+ return null;
876
+ try {
877
+ const groupId = Number(execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' }).trim());
878
+ return Number.isInteger(groupId) && groupId > 1 ? groupId : null;
879
+ }
880
+ catch {
881
+ return null;
882
+ }
883
+ }
884
+ async function waitForGone(label, pid, ms = 1_000) {
885
+ const deadline = Date.now() + ms;
886
+ while (pidAlive(pid)) {
887
+ if (Date.now() >= deadline)
888
+ throw new Error(`${label}: pid=${pid} 在 ${ms}ms 内仍存活`);
889
+ await new Promise((resolve) => setImmediate(resolve));
890
+ }
891
+ }
892
+ function processGroupAlive(groupId) {
893
+ try {
894
+ process.kill(-groupId, 0);
895
+ return true;
896
+ }
897
+ catch {
898
+ return false;
899
+ }
900
+ }
901
+ // 创建完成 promise(必须在事件触发前创建,故在 spawn 后立即调用)。21a/21c 用 close 等流排空;
902
+ // mode='exit' 在 exit 后立即 destroy 流(有 detached grandchild 持 stderr 管道,避免悬挂)。带超时兜底:
903
+ // 超时则 SIGKILL 整个进程组 + destroy 流并 resolve(timedOut:true)——由调用方断言,永不 reject。
904
+ function runFixture(child, mode, _label, ms = 30_000) {
905
+ return new Promise((resolve) => {
906
+ let done = false;
907
+ const destroyStreams = () => { try {
908
+ child.stdout.destroy();
909
+ }
910
+ catch { /* ignore */ } try {
911
+ child.stderr.destroy();
912
+ }
913
+ catch { /* ignore */ } };
914
+ const finish = (info) => {
915
+ if (done)
916
+ return;
917
+ done = true;
918
+ clearTimeout(t);
919
+ resolve({ exitCode: info.exitCode, signal: info.signal, timedOut: !!info.timedOut });
920
+ };
921
+ const t = setTimeout(() => {
922
+ void (async () => {
923
+ await reapFixture(child);
924
+ destroyStreams();
925
+ finish({ exitCode: null, signal: null, timedOut: true });
926
+ })();
927
+ }, ms);
928
+ // 所有 waiter 在调用后立即挂上,避免先 await exit 再错过 close;21a 的完成条件是 close。
929
+ child.once('exit', (c, sig) => { if (mode === 'exit') {
930
+ destroyStreams();
931
+ finish({ exitCode: c, signal: sig });
932
+ } });
933
+ child.on('error', () => { destroyStreams(); finish({ exitCode: -2, signal: null }); });
934
+ child.once('close', (c, sig) => { if (mode === 'close')
935
+ finish({ exitCode: c, signal: sig }); });
936
+ });
937
+ }
938
+ // 清理兜底:kill/reap 进程组 + 关闭流。幂等。任一超时/断言失败/正常退出后调用,确保无悬挂 grandchild。
939
+ async function reapFixture(child) {
940
+ const existing = fixtureReaps.get(child);
941
+ if (existing)
942
+ return existing;
943
+ const reap = (async () => {
944
+ if (child.pid) {
945
+ try {
946
+ process.kill(-child.pid, 'SIGKILL');
947
+ }
948
+ catch { /* dead */ }
949
+ }
950
+ try {
951
+ child.stdin.destroy();
952
+ }
953
+ catch { /* ignore */ }
954
+ await waitForChildCloseEvent(child, 1_000);
955
+ try {
956
+ child.stdout.destroy();
957
+ }
958
+ catch { /* ignore */ }
959
+ try {
960
+ child.stderr.destroy();
961
+ }
962
+ catch { /* ignore */ }
963
+ })();
964
+ fixtureReaps.set(child, reap);
965
+ return reap;
966
+ }
967
+ // 20g. prompt waiter 超时本身必须完成进程组清理,不能把 kill/reap 责任留给调用方 finally。
968
+ // 父进程先启动并报告同组 grandchild;清理验证等待父/后代都消失,不依赖固定 sleep 调度。
969
+ if (process.platform === 'win32') {
970
+ console.log('20g. prompt waiter timeout → process-group grandchild reap(SKIP on win32:POSIX process groups 不适用)');
971
+ }
972
+ else {
973
+ const child = spawn(process.execPath, ['-e', [
974
+ "const { spawn } = require('node:child_process')",
975
+ "const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })",
976
+ "process.stdout.write('grandchild-ready ' + grandchild.pid + '\\n')",
977
+ 'setInterval(() => {}, 1000)',
978
+ ].join(';')], {
979
+ stdio: ['pipe', 'pipe', 'pipe'],
980
+ detached: true,
981
+ });
982
+ const stdoutRef = { s: '' };
983
+ child.stdout.on('data', (chunk) => { stdoutRef.s += chunk.toString('utf8'); });
984
+ try {
985
+ await waitForStdout(child, stdoutRef, 'grandchild-ready ', '20g', 1_000);
986
+ const grandchildPid = Number(stdoutRef.s.match(/grandchild-ready (\d+)/)?.[1]);
987
+ assert.ok(Number.isInteger(grandchildPid) && grandchildPid > 1, `20g: 必须报告有效 grandchild pid\n${stdoutRef.s}`);
988
+ assert.equal(pidAlive(grandchildPid), true, `20g: grandchild(pid=${grandchildPid}) 必须在 waiter 前存活`);
989
+ const parentGroup = processGroupId(child.pid);
990
+ const grandchildGroup = processGroupId(grandchildPid);
991
+ assert.ok(parentGroup, `20g: 无法读取 parent(pid=${child.pid}) PGID`);
992
+ assert.equal(grandchildGroup, parentGroup, `20g: parent/grandchild 必须同组(parent=${parentGroup},grandchild=${grandchildGroup})`);
993
+ await assert.rejects(waitForPrompt(child, stdoutRef, '20g', 0), /未在 0ms 内观察到 onboarding prompt/);
994
+ await waitForGone('20g parent', child.pid, 1_000);
995
+ await waitForGone('20g grandchild', grandchildPid, 1_000);
996
+ assert.equal(pidAlive(child.pid), false, '20g: prompt waiter 失败后 parent 必须消失');
997
+ assert.equal(pidAlive(grandchildPid), false, '20g: prompt waiter 失败后 grandchild 必须消失');
998
+ assert.equal(processGroupAlive(parentGroup), false, `20g: process group(${parentGroup}) 必须无残留成员`);
999
+ }
1000
+ finally {
1001
+ await reapFixture(child);
1002
+ }
1003
+ }
1004
+ console.log('20g. prompt waiter timeout → active same-group grandchild + bounded parent/descendant reap OK');
1005
+ // 20h. cleanup timeout/stream destroy failure 不能吞掉原始 waiter 错误或制造 unhandled rejection。
1006
+ {
1007
+ const fakeChild = new EventEmitter();
1008
+ const failingStream = Object.assign(new EventEmitter(), {
1009
+ destroy() { throw new Error('synthetic cleanup stream failure'); },
1010
+ });
1011
+ Object.assign(fakeChild, {
1012
+ pid: 2_147_483_647,
1013
+ stdin: failingStream,
1014
+ stdout: failingStream,
1015
+ stderr: failingStream,
1016
+ });
1017
+ const stdoutRef = { s: '' };
1018
+ let unhandled = null;
1019
+ const onUnhandled = (reason) => { unhandled = reason; };
1020
+ process.once('unhandledRejection', onUnhandled);
1021
+ const started = Date.now();
1022
+ try {
1023
+ await assert.rejects(waitForPrompt(fakeChild, stdoutRef, '20h', 0), /未在 0ms 内观察到 onboarding prompt/);
1024
+ await new Promise((resolve) => setImmediate(resolve));
1025
+ assert.equal(unhandled, null, '20h: cleanup timeout/failure 不得产生 unhandled rejection');
1026
+ assert.ok(Date.now() - started < 1_500, '20h: cleanup timeout 必须有界返回');
1027
+ }
1028
+ finally {
1029
+ process.off('unhandledRejection', onUnhandled);
1030
+ }
1031
+ }
1032
+ console.log('20h. waiter 原始错误 + cleanup timeout/failure bounded and handled OK');
1033
+ // 21a. TTY eligible,喂 "n":真实 prompt 恰好一次 → 拒绝 → web 启动,摘要抑制,无 result 目录残留
1034
+ {
1035
+ const tmpRoot = mkdtempSync(join(tmpdir(), 'gotry-pkg-21a-'));
1036
+ let child = null;
1037
+ try {
1038
+ const fixture = buildPkgFixture(tmpRoot);
1039
+ child = spawnInner(fixture, { tty: true });
1040
+ const { stdoutRef, getStderr } = collectOutput(child);
1041
+ const done = runFixture(child, 'close', '21a'); // prompted → 无 detached doctor → 等 close 排空流
1042
+ await waitForPrompt(child, stdoutRef, '21a');
1043
+ child.stdin.write('n\n');
1044
+ const res = await done;
1045
+ const stdout = stdoutRef.s;
1046
+ const stderr = getStderr();
1047
+ assert.equal(res.timedOut, false, `21a: 应正常退出而非超时\nstderr:\n${stderr.slice(-1500)}\nstdout:\n${stdout.slice(-800)}`);
1048
+ assert.equal(res.exitCode, 0, `21a: inner web(n) 应 exit 0\nstderr:\n${stderr.slice(-1500)}\nstdout:\n${stdout.slice(-800)}`);
1049
+ const promptCount = (stdout.match(/现在自动配置可安装项吗/g) ?? []).length;
1050
+ assert.equal(promptCount, 1, '21a: 真实 onboarding prompt 恰好出现一次(跨过 inner→bootstrap 边界)');
1051
+ assert.ok(!stdout.includes('开始自动配置'), '21a: 拒绝 → 不安装');
1052
+ assert.ok(!stdout.includes('配置结果:'), '21a: 拒绝 → 无安装结果行');
1053
+ assert.ok(existsSync(fixture.markerPath), '21a: 假 dsh 被调用(跨过 onboarding 到达 web 启动边界)');
1054
+ const lines = readFileSync(fixture.markerPath, 'utf-8').split('\n').filter((l) => l.trim());
1055
+ assert.equal(lines.length, 1, '21a: 假 dsh 恰好被调用一次');
1056
+ const dshArgs = JSON.parse(lines[0]).args;
1057
+ assert.ok(dshArgs.includes('web') && dshArgs.includes('--no-open'), '21a: 假 dsh 收到 web + --no-open');
1058
+ // 摘要抑制:onboarding prompted → 后台 doctor 摘要不跑(假 dsh 逗留 1.5s 给它露面窗口)
1059
+ assert.ok(!stderr.includes('[gotry] doctor:'), '21a: onboarding prompted → 重复后台摘要被抑制');
1060
+ // TMPDIR 无残留 gotry-onboarding-* / gotry-cordis-*(正常退出清理)
1061
+ const leftovers = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-onboarding-') || n.startsWith('gotry-cordis-'));
1062
+ assert.equal(leftovers.length, 0, `21a: 正常退出清了 temp 目录,残留: ${leftovers.join(',')}`);
1063
+ }
1064
+ finally {
1065
+ if (child)
1066
+ await reapFixture(child);
1067
+ try {
1068
+ rmSync(tmpRoot, { recursive: true, force: true });
1069
+ }
1070
+ catch { /* ignore */ }
1071
+ }
1072
+ }
1073
+ console.log('21a. TTY-eligible installed-package inner(真实 prompt 恰好一次 → n → web,摘要抑制,无 temp 残留)OK');
1074
+ // 21b. non-TTY(无 preload):stdin pipe → onboarding 跳过 → 零 prompt,假 dsh 恰好一次
1075
+ {
1076
+ const tmpRoot = mkdtempSync(join(tmpdir(), 'gotry-pkg-21b-'));
1077
+ let child = null;
1078
+ try {
1079
+ const fixture = buildPkgFixture(tmpRoot);
1080
+ child = spawnInner(fixture, { tty: false });
1081
+ const { stdoutRef, getStderr } = collectOutput(child);
1082
+ // non-TTY → onboarding 跳过 → runSummary 跑 detached doctor --summary(持 stderr 管道);用 'exit' 不等 'close',
1083
+ // exit 后立即 destroy 读端令其 EPIPE 自退,避免悬挂。doctor 只读零写盘,不触共享状态。
1084
+ const res = await runFixture(child, 'exit', '21b');
1085
+ const stdout = stdoutRef.s;
1086
+ const stderr = getStderr();
1087
+ assert.equal(res.timedOut, false, `21b: 应正常退出而非超时\nstderr:\n${stderr.slice(-1200)}`);
1088
+ assert.equal(res.exitCode, 0, `21b: non-TTY inner web 应 exit 0\nstderr:\n${stderr.slice(-1200)}\nstdout:\n${stdout.slice(-600)}`);
1089
+ assert.ok(!stdout.includes('现在自动配置可安装项吗'), '21b: 非 TTY 路径不应 prompt');
1090
+ assert.ok(!stdout.includes('开始自动配置'), '21b: 非 TTY 路径不应安装');
1091
+ assert.ok(existsSync(fixture.markerPath), '21b: 假 dsh 被调用');
1092
+ const lines = readFileSync(fixture.markerPath, 'utf-8').split('\n').filter((l) => l.trim());
1093
+ assert.equal(lines.length, 1, '21b: 假 dsh 恰好被调用一次');
1094
+ const dshArgs = JSON.parse(lines[0]).args;
1095
+ assert.ok(dshArgs.includes('web') && dshArgs.includes('--no-open'), '21b: 假 dsh 收到 web + --no-open');
1096
+ }
1097
+ finally {
1098
+ if (child)
1099
+ await reapFixture(child);
1100
+ try {
1101
+ rmSync(tmpRoot, { recursive: true, force: true });
1102
+ }
1103
+ catch { /* ignore */ }
1104
+ }
1105
+ }
1106
+ console.log('21b. non-TTY installed-package inner(零 prompt,假 dsh 恰好一次,全隔离)OK');
1107
+ // 21c. POSIX 信号清理(仅非 win32):onboarding 阻塞等输入时向父进程单独发 SIGTERM → 清 result+patch 目录,无残留子进程
1108
+ if (process.platform === 'win32') {
1109
+ console.log('21c. POSIX signal cleanup(SKIP on win32:POSIX 信号子例不适用,平台限制)');
1110
+ }
1111
+ else {
1112
+ const tmpRoot = mkdtempSync(join(tmpdir(), 'gotry-pkg-21c-'));
1113
+ let child = null;
1114
+ try {
1115
+ const fixture = buildPkgFixture(tmpRoot);
1116
+ child = spawnInner(fixture, { tty: true });
1117
+ const { stdoutRef, getStderr } = collectOutput(child);
1118
+ // 完成 promise 必须在 process.kill 之前创建,避免错过 'exit'/'close' 事件。
1119
+ // 信号时未到 launchWeb → 无 detached doctor grandchild → 等 close 排空流。
1120
+ const done = runFixture(child, 'close', '21c', 15_000);
1121
+ // 先观察到真实 prompt(证明 onboarding 子进程真的在等输入)
1122
+ await waitForPrompt(child, stdoutRef, '21c');
1123
+ // 不喂答案,onboarding 阻塞等输入;向父进程(inner)单独发 SIGTERM
1124
+ process.kill(child.pid, 'SIGTERM');
1125
+ const res = await done;
1126
+ assert.equal(res.timedOut, false, '21c: inner 应在超时前响应 SIGTERM 终止(超时=FAIL,非 skip)');
1127
+ // 给 OS 一点回收时间
1128
+ await new Promise((r) => setTimeout(r, 500));
1129
+ // TMPDIR 无残留 gotry-onboarding-* 与 gotry-cordis-*(信号路径清理)
1130
+ const leftoverOnboard = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-onboarding-'));
1131
+ const leftoverCordis = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-cordis-'));
1132
+ assert.equal(leftoverOnboard.length, 0, `21c: 信号路径清了 result 目录,残留: ${leftoverOnboard.join(',')}`);
1133
+ assert.equal(leftoverCordis.length, 0, `21c: 信号路径清了 patch 目录,残留: ${leftoverCordis.join(',')}`);
1134
+ // 进程组无残留子进程
1135
+ let groupAlive = true;
1136
+ try {
1137
+ process.kill(-child.pid, 0);
1138
+ }
1139
+ catch {
1140
+ groupAlive = false;
1141
+ }
1142
+ assert.equal(groupAlive, false, '21c: inner 退出后进程组无残留子进程');
1143
+ // 信号时还在 onboarding,未到 launchWeb → 假 dsh 未被调用
1144
+ assert.ok(!existsSync(fixture.markerPath), '21c: 信号时未到 web 启动,假 dsh 未被调用');
1145
+ void getStderr;
1146
+ }
1147
+ finally {
1148
+ if (child)
1149
+ await reapFixture(child);
1150
+ try {
1151
+ rmSync(tmpRoot, { recursive: true, force: true });
1152
+ }
1153
+ catch { /* ignore */ }
1154
+ }
1155
+ console.log('21c. POSIX signal cleanup(SIGTERM→清 result+patch 目录,无残留子进程;win32 skip)OK');
1156
+ }
1157
+ // 21f. POSIX yes 安装期间信号:进入真实 hbcli installer 后只打父进程,installer 子树也必须被进程组转发/升级清理。
1158
+ if (process.platform === 'win32') {
1159
+ console.log('21f. POSIX yes-path installer signal cleanup(SKIP on win32:POSIX 安装器进程组子例不适用,平台限制)');
1160
+ }
1161
+ else {
1162
+ const tmpRoot = mkdtempSync(join(tmpdir(), 'gotry-pkg-21f-'));
1163
+ let child = null;
1164
+ try {
1165
+ const fixture = buildPkgFixture(tmpRoot);
1166
+ const installer = addSleepingInstallerFixture(tmpRoot, fixture);
1167
+ child = spawnInner(fixture, {
1168
+ tty: true,
1169
+ env: {
1170
+ PATH: installer.path,
1171
+ GOTRY_SETUP_REACH: '0',
1172
+ GOTRY_SETUP_SIDEBAR: '0',
1173
+ GOTRY_TEST_INSTALLER_MARKER: installer.installerMarkerPath,
1174
+ },
1175
+ });
1176
+ const { stdoutRef, getStderr } = collectOutput(child);
1177
+ const done = runFixture(child, 'close', '21f', 15_000);
1178
+ await waitForPrompt(child, stdoutRef, '21f');
1179
+ child.stdin.write('y\n');
1180
+ await waitForStdout(child, stdoutRef, '开始自动配置可安装项', '21f');
1181
+ await waitForFileText(child, installer.installerMarkerPath, 'stubborn-child-ready ', stdoutRef, '21f');
1182
+ process.kill(child.pid, 'SIGTERM');
1183
+ const res = await done;
1184
+ const stderr = getStderr();
1185
+ assert.equal(res.timedOut, false, `21f: yes 安装期间 parent SIGTERM 应有界终止\nstderr:\n${stderr.slice(-1200)}\nstdout:\n${stdoutRef.s.slice(-800)}`);
1186
+ assert.ok(res.signal === 'SIGTERM' || res.exitCode === 143, `21f: 父进程应以 SIGTERM 或 143 fallback 终止,实际 code=${res.exitCode},signal=${res.signal}\nstderr:\n${stderr.slice(-1200)}\nstdout:\n${stdoutRef.s.slice(-800)}`);
1187
+ await new Promise((r) => setTimeout(r, 500));
1188
+ const installerLog = readFileSync(installer.installerMarkerPath, 'utf-8');
1189
+ assert.match(installerLog, /bash-start \d+/, '21f: fake bash installer 已启动');
1190
+ assert.match(installerLog, /bash-term-ignored \d+/, `21f: fake installer 必须忽略 TERM,证明后续依赖 SIGKILL group\n${installerLog}`);
1191
+ assert.match(installerLog, /child-term-ignored \d+/, `21f: stubborn child 必须已装好 handler 并忽略 TERM\n${installerLog}`);
1192
+ const sleepPid = Number((installerLog.match(/sleep-pid (\d+)/) ?? [])[1]);
1193
+ assert.ok(Number.isInteger(sleepPid) && sleepPid > 1, `21f: installer log 应记录 sleep 子进程 pid\n${installerLog}`);
1194
+ assert.equal(pidAlive(sleepPid), false, `21f: fake installer 的 sleep 子进程应已被 reap(pid=${sleepPid})\n${installerLog}`);
1195
+ assert.ok(!installerLog.includes('bash-finished'), `21f: 安装器不应自然跑完\n${installerLog}`);
1196
+ const leftoverOnboard = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-onboarding-'));
1197
+ const leftoverCordis = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-cordis-'));
1198
+ assert.equal(leftoverOnboard.length, 0, `21f: yes 安装信号路径清了 result 目录,残留: ${leftoverOnboard.join(',')}`);
1199
+ assert.equal(leftoverCordis.length, 0, `21f: yes 安装信号路径清了 patch 目录,残留: ${leftoverCordis.join(',')}`);
1200
+ let groupAlive = true;
1201
+ try {
1202
+ process.kill(-child.pid, 0);
1203
+ }
1204
+ catch {
1205
+ groupAlive = false;
1206
+ }
1207
+ assert.equal(groupAlive, false, '21f: yes 安装信号后 inner 进程组无残留子进程');
1208
+ assert.ok(!existsSync(fixture.markerPath), '21f: 信号时仍在安装,onboarding 未到 web 启动,假 dsh 未被调用');
1209
+ }
1210
+ finally {
1211
+ if (child)
1212
+ await reapFixture(child);
1213
+ try {
1214
+ rmSync(tmpRoot, { recursive: true, force: true });
1215
+ }
1216
+ catch { /* ignore */ }
1217
+ }
1218
+ console.log('21f. POSIX yes-path installer signal cleanup(parent SIGTERM during fake hbcli install → installer subtree reaped + no temp/process leftovers)OK');
1219
+ }
1220
+ // 21g. POSIX yes 安装期间 onboarding timeout:outer grace 必须覆盖 bootstrap installer TERM+SIGKILL budget。
1221
+ if (process.platform === 'win32') {
1222
+ console.log('21g. POSIX yes-path installer timeout cleanup(SKIP on win32:POSIX 安装器进程组子例不适用,平台限制)');
1223
+ }
1224
+ else {
1225
+ const tmpRoot = mkdtempSync(join(tmpdir(), 'gotry-pkg-21g-'));
1226
+ let child = null;
1227
+ try {
1228
+ const fixture = buildPkgFixture(tmpRoot);
1229
+ const installer = addSleepingInstallerFixture(tmpRoot, fixture);
1230
+ child = spawnInner(fixture, {
1231
+ tty: true,
1232
+ env: {
1233
+ PATH: installer.path,
1234
+ GOTRY_SETUP_REACH: '0',
1235
+ GOTRY_SETUP_SIDEBAR: '0',
1236
+ GOTRY_TEST_INSTALLER_MARKER: installer.installerMarkerPath,
1237
+ GOTRY_ONBOARDING_CHILD_TIMEOUT_MS: '5000',
1238
+ },
1239
+ });
1240
+ const { stdoutRef, getStderr } = collectOutput(child);
1241
+ const done = runFixture(child, 'exit', '21g', 30_000);
1242
+ await waitForPrompt(child, stdoutRef, '21g');
1243
+ child.stdin.write('y\n');
1244
+ await waitForStdout(child, stdoutRef, '开始自动配置可安装项', '21g');
1245
+ await waitForFileText(child, installer.installerMarkerPath, 'stubborn-child-ready ', stdoutRef, '21g');
1246
+ const res = await done;
1247
+ const stderr = getStderr();
1248
+ assert.equal(res.timedOut, false, `21g: accepted install timeout 后应继续 web,不应整体超时\nstderr:\n${stderr.slice(-1200)}\nstdout:\n${stdoutRef.s.slice(-800)}`);
1249
+ assert.equal(res.exitCode, 0, `21g: accepted install timeout 后 inner 继续 web 应 exit 0\nstderr:\n${stderr.slice(-1200)}\nstdout:\n${stdoutRef.s.slice(-800)}`);
1250
+ assert.match(stderr, /onboarding child timeout after 5000ms; continuing to web/, '21g: stderr 应含精简 timeout 诊断');
1251
+ assert.ok(existsSync(fixture.markerPath), '21g: timeout 后应继续到 fake dsh web');
1252
+ const lines = readFileSync(fixture.markerPath, 'utf-8').split('\n').filter((l) => l.trim());
1253
+ assert.equal(lines.length, 1, '21g: fake dsh 恰好启动一次');
1254
+ await new Promise((r) => setTimeout(r, 500));
1255
+ const installerLog = readFileSync(installer.installerMarkerPath, 'utf-8');
1256
+ assert.match(installerLog, /bash-start \d+/, '21g: fake bash installer 已启动');
1257
+ assert.match(installerLog, /bash-term-ignored \d+/, `21g: fake installer 必须忽略 TERM,证明 timeout 依赖 SIGKILL group\n${installerLog}`);
1258
+ assert.match(installerLog, /child-term-ignored \d+/, `21g: stubborn child 必须已装好 handler 并忽略 TERM\n${installerLog}`);
1259
+ const sleepPid = Number((installerLog.match(/sleep-pid (\d+)/) ?? [])[1]);
1260
+ assert.ok(Number.isInteger(sleepPid) && sleepPid > 1, `21g: installer log 应记录 stubborn 子进程 pid\n${installerLog}`);
1261
+ assert.equal(pidAlive(sleepPid), false, `21g: timeout 后 stubborn installer 子进程应已被 reap(pid=${sleepPid})\n${installerLog}`);
1262
+ assert.ok(!installerLog.includes('bash-finished'), `21g: stubborn 安装器不应自然跑完\n${installerLog}`);
1263
+ const leftoverOnboard = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-onboarding-'));
1264
+ const leftoverCordis = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-cordis-'));
1265
+ assert.equal(leftoverOnboard.length, 0, `21g: timeout yes 安装路径清了 result 目录,残留: ${leftoverOnboard.join(',')}`);
1266
+ assert.equal(leftoverCordis.length, 0, `21g: timeout yes 安装路径清了 patch 目录,残留: ${leftoverCordis.join(',')}`);
1267
+ let groupAlive = true;
1268
+ try {
1269
+ process.kill(-child.pid, 0);
1270
+ }
1271
+ catch {
1272
+ groupAlive = false;
1273
+ }
1274
+ assert.equal(groupAlive, false, '21g: timeout yes 安装后 inner 进程组无残留子进程');
1275
+ }
1276
+ finally {
1277
+ if (child)
1278
+ await reapFixture(child);
1279
+ try {
1280
+ rmSync(tmpRoot, { recursive: true, force: true });
1281
+ }
1282
+ catch { /* ignore */ }
1283
+ }
1284
+ console.log('21g. POSIX yes-path installer timeout cleanup(onboarding timeout during fake hbcli install → installer SIGKILL group + web continues)OK');
1285
+ }
1286
+ // 21d. 生产 onboarding 超时上界:TTY eligible + 注入 1000ms 超时 + 不喂输入 → onboarding 挂起被 SIGTERM/SIGKILL,
1287
+ // inner 继续 web(prompted:false),无 result 目录残留,exit 0。证明超时不挡 web、不留进程/目录。
1288
+ {
1289
+ const tmpRoot = mkdtempSync(join(tmpdir(), 'gotry-pkg-21d-'));
1290
+ let child = null;
1291
+ try {
1292
+ const fixture = buildPkgFixture(tmpRoot);
1293
+ child = spawnInner(fixture, { tty: true, env: { GOTRY_ONBOARDING_CHILD_TIMEOUT_MS: '1000' } });
1294
+ const { stdoutRef, getStderr } = collectOutput(child);
1295
+ // 超时后 prompted:false → runSummary 跑 detached doctor(持 stderr);用 'exit' + destroy 避免悬挂。
1296
+ const done = runFixture(child, 'exit', '21d', 30_000);
1297
+ // 先观察到 prompt(证明 onboarding 真的进入了可 prompt 路径并在等输入)
1298
+ await waitForPrompt(child, stdoutRef, '21d');
1299
+ // 不喂输入:1s 后 inner 超时杀 onboarding 子,继续 web
1300
+ const res = await done;
1301
+ const stdout = stdoutRef.s;
1302
+ const stderr = getStderr();
1303
+ assert.equal(res.timedOut, false, `21d: 超时后应继续 web 并正常退出而非整体超时\nstderr:\n${stderr.slice(-1200)}`);
1304
+ assert.equal(res.exitCode, 0, `21d: 超时后 inner 继续 web 应 exit 0\nstderr:\n${stderr.slice(-1200)}\nstdout:\n${stdout.slice(-600)}`);
1305
+ assert.ok(existsSync(fixture.markerPath), '21d: onboarding 超时后 inner 继续 web → 假 dsh 被调用(超时不挡 web)');
1306
+ const lines = readFileSync(fixture.markerPath, 'utf-8').split('\n').filter((l) => l.trim());
1307
+ assert.equal(lines.length, 1, '21d: 假 dsh 恰好被调用一次');
1308
+ assert.match(stderr, /onboarding child timeout after 1000ms; continuing to web/, '21d: stderr 应含精简 timeout 诊断');
1309
+ // 超时路径清了 result 目录(finally)
1310
+ const leftoverOnboard = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-onboarding-'));
1311
+ const leftoverCordis = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-cordis-'));
1312
+ assert.equal(leftoverOnboard.length, 0, `21d: 超时路径清了 result 目录,残留: ${leftoverOnboard.join(',')}`);
1313
+ assert.equal(leftoverCordis.length, 0, `21d: 超时后正常退出清了 patch 目录,残留: ${leftoverCordis.join(',')}`);
1314
+ await new Promise((r) => setTimeout(r, 500));
1315
+ let groupAlive = true;
1316
+ try {
1317
+ process.kill(-child.pid, 0);
1318
+ }
1319
+ catch {
1320
+ groupAlive = false;
1321
+ }
1322
+ assert.equal(groupAlive, false, '21d: 超时后进程组无残留子进程');
1323
+ }
1324
+ finally {
1325
+ if (child)
1326
+ await reapFixture(child);
1327
+ try {
1328
+ rmSync(tmpRoot, { recursive: true, force: true });
1329
+ }
1330
+ catch { /* ignore */ }
1331
+ }
1332
+ }
1333
+ console.log('21d. onboarding 超时上界(1000ms 注入 → 杀子继续 web,无残留,exit 0)OK');
1334
+ // 21e. POSIX later-lifecycle signal:先拒绝 onboarding,等假 dsh 已启动,只打父进程 SIGTERM。
1335
+ // 证明信号转发监听器没有在 onboarding 后过早注销,仍会终止活跃 dsh 子进程并清 patch/result。
1336
+ if (process.platform === 'win32') {
1337
+ console.log('21e. POSIX later-lifecycle signal forwarding(SKIP on win32:POSIX 信号子例不适用,平台限制)');
1338
+ }
1339
+ else {
1340
+ const tmpRoot = mkdtempSync(join(tmpdir(), 'gotry-pkg-21e-'));
1341
+ let child = null;
1342
+ try {
1343
+ const fixture = buildPkgFixture(tmpRoot);
1344
+ child = spawnInner(fixture, { tty: true, env: { FAKE_DSH_SLEEP_MS: '30000' } });
1345
+ const { stdoutRef, getStderr } = collectOutput(child);
1346
+ const done = runFixture(child, 'close', '21e', 15_000);
1347
+ await waitForPrompt(child, stdoutRef, '21e');
1348
+ child.stdin.write('n\n');
1349
+ await waitForStdout(child, stdoutRef, '[fake-dsh] web launch', '21e');
1350
+ process.kill(child.pid, 'SIGTERM');
1351
+ const res = await done;
1352
+ const stderr = getStderr();
1353
+ assert.equal(res.timedOut, false, `21e: inner 应在超时前响应 later-lifecycle SIGTERM\nstderr:\n${stderr.slice(-1200)}`);
1354
+ assert.ok(res.signal === 'SIGTERM' || res.exitCode === 143, `21e: 父进程应以 SIGTERM 或 143 fallback 终止,实际 code=${res.exitCode},signal=${res.signal}\nstderr:\n${stderr.slice(-1200)}`);
1355
+ const lines = readFileSync(fixture.markerPath, 'utf-8').split('\n').filter((l) => l.trim());
1356
+ assert.equal(lines.length, 1, '21e: 假 dsh 恰好启动一次');
1357
+ await new Promise((r) => setTimeout(r, 500));
1358
+ const leftoverOnboard = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-onboarding-'));
1359
+ const leftoverCordis = readdirSync(fixture.tmpDir).filter((n) => n.startsWith('gotry-cordis-'));
1360
+ assert.equal(leftoverOnboard.length, 0, `21e: later-lifecycle 信号后无 result 目录残留: ${leftoverOnboard.join(',')}`);
1361
+ assert.equal(leftoverCordis.length, 0, `21e: later-lifecycle 信号后无 patch 目录残留: ${leftoverCordis.join(',')}`);
1362
+ let groupAlive = true;
1363
+ try {
1364
+ process.kill(-child.pid, 0);
1365
+ }
1366
+ catch {
1367
+ groupAlive = false;
1368
+ }
1369
+ assert.equal(groupAlive, false, '21e: later-lifecycle 信号后进程组无残留子进程');
1370
+ }
1371
+ finally {
1372
+ if (child)
1373
+ await reapFixture(child);
1374
+ try {
1375
+ rmSync(tmpRoot, { recursive: true, force: true });
1376
+ }
1377
+ catch { /* ignore */ }
1378
+ }
1379
+ console.log('21e. POSIX later-lifecycle signal forwarding(parent SIGTERM after fake dsh active → forwarded + no temp/process leftovers)OK');
1380
+ }
1381
+ console.log('21. spawned installed-package inner(21a TTY-eligible 真实 prompt→n→web + 摘要抑制 + 无残留 / 21b non-TTY 零 prompt / 21c POSIX onboarding 信号清理 / 21f POSIX yes-path installer 信号清理 / 21g POSIX yes-path timeout installer 清理 / 21d onboarding 超时上界 / 21e POSIX dsh-lifecycle 信号转发)OK');
1382
+ // 22. win32 platform boundary:复用 doctor --fix 的平台能力边界(doctorFixAutoSupported)。win32 上
1383
+ // hbcli/agent-reach 上游无 win 安装面、sidebar(dsh plugin→pnpm)在 win32 同样不自动跑——onboarding
1384
+ // 不得把它们分到 auto 桶(否则 prompt 了却装不上);分类为 unavailable 且给具体平台原因。hbcli
1385
+ // degraded(凭证)= user-action(平台无关)。{ platform, env }注入,确定性,不依赖真机平台或 ambient env。
1386
+ {
1387
+ const { classifyDoctorGap, buildOnboardingPlan, doctorFixAutoSupported } = await import(bootstrap);
1388
+ const cleanEnv = {}; // 无 GOTRY_SETUP_*=0 → 安装器全启用,隔离 ambient env
1389
+ assert.equal(doctorFixAutoSupported('darwin'), true);
1390
+ assert.equal(doctorFixAutoSupported('linux'), true);
1391
+ assert.equal(doctorFixAutoSupported('win32'), false, 'win32 doctor --fix 不跑自动安装');
1392
+ // win32: hbcli missing/reach/sidebar → unavailable(非 auto);hbcli degraded → user-action(凭证,平台无关)
1393
+ assert.equal(classifyDoctorGap(onboardingItems[3], { platform: 'win32', env: cleanEnv }), 'unavailable', 'win32 hbcli missing = unavailable');
1394
+ assert.equal(classifyDoctorGap(hbcliDegraded, { platform: 'win32', env: cleanEnv }), 'user-action', 'win32 hbcli degraded = user-action(凭证平台无关)');
1395
+ assert.equal(classifyDoctorGap(onboardingItems[2], { platform: 'win32', env: cleanEnv }), 'unavailable', 'win32 reach = unavailable');
1396
+ assert.equal(classifyDoctorGap(onboardingItems[5], { platform: 'win32', env: cleanEnv }), 'unavailable', 'win32 sidebar = unavailable(与 doctor 守卫同口径)');
1397
+ // darwin 对照:auto 桶正常(不回归)
1398
+ assert.equal(classifyDoctorGap(onboardingItems[3], { platform: 'darwin', env: cleanEnv }), 'auto');
1399
+ assert.equal(classifyDoctorGap(onboardingItems[2], { platform: 'darwin', env: cleanEnv }), 'auto');
1400
+ assert.equal(classifyDoctorGap(onboardingItems[5], { platform: 'darwin', env: cleanEnv }), 'auto');
1401
+ // win32 计划:promptable=false(无 auto,与 doctor --fix 不装一致),unavailable 含三项 + 平台原因
1402
+ const plan = buildOnboardingPlan(onboardingItems, { platform: 'win32', env: cleanEnv });
1403
+ assert.equal(plan.promptable, false, 'win32 无 auto 缺项 → 不 prompt');
1404
+ assert.equal(plan.auto.length, 0);
1405
+ const unavail = new Map(plan.unavailable.map((g) => [g.label, g]));
1406
+ assert.ok(unavail.has('hbcli(酒店实时源)'), 'hbcli 进 unavailable');
1407
+ assert.ok(unavail.has('Agent Reach(网页/社媒读取)'), 'reach 进 unavailable');
1408
+ assert.ok(unavail.has('dsh-better-sidebar(侧栏工作台)'), 'sidebar 进 unavailable');
1409
+ assert.match(unavail.get('hbcli(酒店实时源)').detail, /Windows/, 'hbcli unavailable 给 win 平台具体原因');
1410
+ assert.match(unavail.get('Agent Reach(网页/社媒读取)').detail, /Windows/, 'reach unavailable 给 win 平台具体原因');
1411
+ assert.match(unavail.get('dsh-better-sidebar(侧栏工作台)').detail, /Windows/, 'sidebar unavailable 给 win 平台具体原因');
1412
+ // user-action 项不受平台影响(扩展商店/flyai key/calendar profile)
1413
+ const ua = new Set(plan.userAction.map((g) => g.label));
1414
+ assert.ok(ua.has('GoTry Session Bridge 扩展'), 'win32 扩展仍 user-action(平台无关)');
1415
+ assert.ok(ua.has('FlyAI(飞猪官方检索)'), 'win32 flyai 仍 user-action(平台无关)');
1416
+ assert.ok(ua.has('dsh-calendar(日历工作窗口)'), 'win32 calendar 仍 user-action(平台无关)');
1417
+ // darwin 计划对照:auto=3,promptable=true(不回归)
1418
+ const planDarwin = buildOnboardingPlan(onboardingItems, { platform: 'darwin', env: cleanEnv });
1419
+ assert.equal(planDarwin.promptable, true);
1420
+ assert.equal(planDarwin.auto.length, 3, 'darwin auto=reach+hbcli+sidebar(不回归)');
1421
+ }
1422
+ console.log('22. win32 platform boundary(hbcli/reach/sidebar → unavailable + 具体原因;hbcli degraded → user-action;不 prompt;darwin 不回归;env 注入隔离)OK');
1423
+ // 23. result 通道排他写入(result channel hardening):bootstrap writeResult 用 mode 0600 + flag wx,
1424
+ // inner 在 0700 mkdtemp 私有目录下传 result.json。本测试验证 wx 的两条护城:
1425
+ // (a) 预存普通文件在 result 路径 → wx EEXIST 拒绝覆盖,原内容保留;
1426
+ // (b) result 路径是符号链接指向 victim → wx 不跟随/不覆盖 victim(symlink 互换攻击防护)。
1427
+ // 非 TTY(execFileSync stdin=pipe)→ 走 non-tty 跳过路径仍调 writeResult,正好触发 wx;HOME 隔离。
1428
+ // 不变量:symlink 创建失败(平台不支持)只跳过 (b),(a) 与 CLI/断言必须独立失败——不得被
1429
+ // symlink 回退吞掉 AssertionError(否则 symlink 平台绿、断言红时假绿)。
1430
+ {
1431
+ const isolated = mkdtempSync(join(tmpdir(), 'gotry-result-excl-'));
1432
+ try {
1433
+ // (a) 预存普通文件 → 不被覆盖
1434
+ const resultPath = join(isolated, 'result.json');
1435
+ writeFileSync(resultPath, 'ORIGINAL-NO-OVERWRITE', { mode: 0o600 });
1436
+ const r1 = runBootstrap(['onboarding', `--result-file=${resultPath}`], { HOME: isolated });
1437
+ assert.equal(r1.code, 0, `onboarding 应恒 exit 0\n${r1.out}`);
1438
+ assert.equal(readFileSync(resultPath, 'utf-8'), 'ORIGINAL-NO-OVERWRITE', 'wx: 预存 result 文件不被覆盖(排他写入)');
1439
+ // (b) result 路径是符号链接 → victim 不被跟随/覆盖。symlink 创建单独 try/catch:仅平台不支持时跳过;
1440
+ // CLI 执行与 victim 断言在 catch 之外,symlink 平台上必须如实失败,不回退吞断言。
1441
+ const victimDir = mkdtempSync(join(tmpdir(), 'gotry-victim-'));
1442
+ try {
1443
+ const victimFile = join(victimDir, 'victim.txt');
1444
+ writeFileSync(victimFile, 'VICTIM-ORIGINAL', { mode: 0o600 });
1445
+ const symlinkPath = join(isolated, 'symlink-result.json');
1446
+ let symlinkCreated = false;
1447
+ try {
1448
+ symlinkSync(victimFile, symlinkPath);
1449
+ symlinkCreated = true;
1450
+ }
1451
+ catch {
1452
+ // symlink 创建不被支持(win32 无权限/平台限制)→ 跳过 (b);文件态断言 (a) 已覆盖核心 wx 行为
1453
+ }
1454
+ if (symlinkCreated) {
1455
+ const r2 = runBootstrap(['onboarding', `--result-file=${symlinkPath}`], { HOME: isolated });
1456
+ assert.equal(r2.code, 0, `onboarding(symlink)应恒 exit 0\n${r2.out}`);
1457
+ assert.equal(readFileSync(victimFile, 'utf-8'), 'VICTIM-ORIGINAL', 'wx: symlink 不被跟随/覆盖 victim(防 symlink 互换攻击)');
1458
+ }
1459
+ }
1460
+ finally {
1461
+ try {
1462
+ rmSync(victimDir, { recursive: true, force: true });
1463
+ }
1464
+ catch { /* ignore */ }
1465
+ }
1466
+ }
1467
+ finally {
1468
+ try {
1469
+ rmSync(isolated, { recursive: true, force: true });
1470
+ }
1471
+ catch { /* ignore */ }
1472
+ }
1473
+ }
1474
+ console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink 不跟随 victim,mode 0600 + flag wx)OK');
1475
+ console.log('BOOTSTRAP TESTS: 25/25 OK(扩展就位 + 跳过开关 / wizard --dry-run / wizard 真实 / 扩展分发通道 / doctor 体检面 / calendar setup 状态面 / 显式跳过 + auto 跳过 + 单项跳过 / 启动摘要 / sidebar 落盘状态复核 / onboarding 分类+计划+env opt-out+跳过原因+yes+partial-failure+幂等+prompt+CLI 跳过+--scan / 编排缝 orchestrateWebLaunch × 6 / prompt waiter failure deterministic process-group reap / waiter 原始错误 + cleanup timeout/failure handled / 临时安装包 spawned inner 21a TTY-eligible 真实 prompt→n→web + 摘要抑制 + 无残留 / 21b non-TTY 零 prompt / 21c POSIX 信号清理 SIGTERM→清 result+patch 目录 + 无残留子进程 / 21f yes-path installer 子树信号清理 / 21g yes-path timeout installer 子树清理 / 21d timeout 诊断 + 无残留 / 21e dsh-lifecycle 信号转发 / win32 平台边界 / result 通道排他写入)');