@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,193 +1,256 @@
1
+ /**
2
+ * 扩展传输车道合同测试(§38;RFC §2.2 通道 C,2026-08-29 PRIMARY)。
3
+ *
4
+ * 全离线确定性:临时端口桥 + 进程内假扩展客户端(直 fetch 桥端点),不开浏览器、
5
+ * 不碰登录态、不写共享状态(审计走 mkdtemp 隔离)。唯一慢例 = needs-extension 全链
6
+ * (等扩展连接宽限 ~6s——这正是「零花费 no-spend」语义本身)。
7
+ *
8
+ * 三类断言:
9
+ * ① 合同面:manifest(MV3/key→固定 ID 派生/host_permissions 精确面/双 world content_scripts)
10
+ * 与 Node 常量防漂移(BRIDGE_PORTS/LOGIN_COOKIE_NAMES/NETWORK_HINTS/flights.ctrip.com);
11
+ * ② 桥行为:origin 白名单(邪恶源 403)/ 长轮询取活幂等 / 心跳判定 / 提交-回包闭环 /
12
+ * 超时与扩展未连接(有界等待 no-spend)/ close 结算;
13
+ * ③ 车道语义:classifyBridgeFailure(只有 extension-not-connected 是用户门)/
14
+ * resolveTransportMode(扩展默认,cdp 显式 opt-in)/ 审计 JSONL /
15
+ * needs-extension 全链 + waiting_extension 双源合同(no_spend_waiting_user)。
16
+ */
1
17
  import assert from 'node:assert/strict';
2
18
  import { fork } from 'node:child_process';
3
19
  import { createHash } from 'node:crypto';
4
20
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
21
  import { tmpdir } from 'node:os';
6
22
  import { join } from 'node:path';
23
+ import { runInNewContext } from 'node:vm';
7
24
  import { fileURLToPath } from 'node:url';
8
- import { BRIDGE_PORTS, EXTENSION_ID, EXTENSION_ID_STORE, EXTENSION_ORIGIN, EXTENSION_ORIGIN_STORE, EXTENSION_ORIGINS, EXTENSION_STORE_URL, createSessionBridge, localExtensionInstalled, needsExtensionSummary, __resetSessionBridgeForTest, __setSessionBridgeForTest } from '../capabilities/session/extension-bridge.js';
9
- import { classifyBridgeFailure, extensionCookieNames, extensionSearchJob } from '../capabilities/session/extension-channel.js';
25
+ import { BRIDGE_PORTS, EXTENSION_ID, EXTENSION_ID_STORE, EXTENSION_ORIGIN, EXTENSION_ORIGIN_STORE, EXTENSION_ORIGINS, EXTENSION_STORE_URL, createSessionBridge, localExtensionInstalled, needsExtensionSummary, __resetSessionBridgeForTest, __setSessionBridgeForTest, } from '../capabilities/session/extension-bridge.js';
26
+ import { classifyBridgeFailure, extensionCookieNames, extensionSearchJob, } from '../capabilities/session/extension-channel.js';
10
27
  import { appendExtensionAudit, resolveTransportMode, sessionFlightSearch, sessionHotelSearch, sessionTrainSearch, __resetRateLimiterForTest } from '../capabilities/session-search.js';
11
28
  import { sessionLogin } from '../capabilities/session-login.js';
12
29
  import { LOGIN_COOKIE_NAMES, NETWORK_HINTS, SITE_DOMAIN } from '../capabilities/session/adapters/ctrip-flight.js';
13
- import { HOTEL_NETWORK_HINTS, HOTEL_SITE_HOST, buildHotelEntryUrl } from '../capabilities/session/adapters/ctrip-hotel.js';
30
+ import { HOTEL_NETWORK_HINTS, HOTEL_SITE_HOST } from '../capabilities/session/adapters/ctrip-hotel.js';
14
31
  import { TRAIN_NETWORK_HINTS, TRAIN_SITE_HOST } from '../capabilities/session/adapters/rail-12306.js';
32
+ import { DIDA_NETWORK_HINTS, DIDA_LOGIN_COOKIE_NAMES, DIDA_SITE_HOST } from '../capabilities/session/adapters/dida-portal.js';
15
33
  import { evaluateDoubleSource } from '../capabilities/session/benchmark.js';
34
+ import { factsFromSessionTrain } from '../src/bookable-facts.js';
16
35
  const EXT_DIR = fileURLToPath(new URL('../../extension/', import.meta.url));
17
36
  const UNREF_CHILD = fileURLToPath(new URL('./fixtures/extension-bridge-unref-child.mjs', import.meta.url));
18
- const read = (f)=>readFileSync(join(EXT_DIR, f), 'utf8');
37
+ const read = (f) => readFileSync(join(EXT_DIR, f), 'utf8');
38
+ async function contentMainRelativeResponseUrlProof(source) {
39
+ const events = [];
40
+ const window = {
41
+ __gotrySniffInstalled: false,
42
+ fetch: async function (_input, _init) {
43
+ return {
44
+ url: 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM',
45
+ headers: { get: () => null },
46
+ clone: () => ({ text: async () => '{"data":{"result":[]}}' }),
47
+ };
48
+ },
49
+ dispatchEvent: (event) => {
50
+ events.push({ url: String(event.detail?.url ?? ''), body: String(event.detail?.body ?? '') });
51
+ },
52
+ };
53
+ class FixtureXhr {
54
+ responseType = '';
55
+ responseText = '{"data":{"result":[]}}';
56
+ response = this.responseText;
57
+ responseURL = 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM';
58
+ listeners = [];
59
+ addEventListener(event, handler) {
60
+ if (event === 'load')
61
+ this.listeners.push(handler);
62
+ }
63
+ open(_method, _url) { }
64
+ send(_body) {
65
+ for (const handler of this.listeners)
66
+ handler();
67
+ }
68
+ }
69
+ class FixtureCustomEvent {
70
+ detail;
71
+ constructor(_type, init) { this.detail = init.detail; }
72
+ }
73
+ ;
74
+ window.addEventListener = () => { };
75
+ runInNewContext(source, { window, location: { hostname: 'kyfw.12306.cn' }, XMLHttpRequest: FixtureXhr, CustomEvent: FixtureCustomEvent });
76
+ await window.fetch('/otn/leftTicket/queryG', {});
77
+ const xhr = new FixtureXhr();
78
+ xhr.open('GET', '/otn/leftTicket/queryG');
79
+ xhr.send();
80
+ await new Promise((resolve) => setTimeout(resolve, 0));
81
+ return events;
82
+ }
19
83
  let passed = 0;
20
84
  async function check(label, assertion) {
21
85
  await assertion();
22
86
  passed += 1;
23
87
  console.log(` ok - ${label}`);
24
88
  }
25
- const mustBridge = async (ports)=>{
26
- const created = await createSessionBridge({
27
- ports
28
- });
29
- if (!created.ok) throw new Error(created.summary);
89
+ const mustBridge = async (ports) => {
90
+ // 缺省 EXTENSION_ORIGINS(unpacked + 商店版双通道)——行为测试直接覆盖生产默认白名单
91
+ const created = await createSessionBridge({ ports });
92
+ if (!created.ok)
93
+ throw new Error(created.summary);
30
94
  return created.bridge;
31
95
  };
32
- async function claimOnce(port, respond, signal) {
96
+ /** 假扩展客户端:一次长轮询取活 + 回包(Origin=固定扩展源,过桥白名单) */
97
+ async function claimOnce(port, respond, signal, capabilities) {
33
98
  const r = await fetch(`http://127.0.0.1:${port}/jobs`, {
34
99
  method: 'POST',
35
- headers: {
36
- 'content-type': 'application/json',
37
- origin: EXTENSION_ORIGIN
38
- },
39
- body: JSON.stringify({
40
- extensionVersion: 'test'
41
- }),
42
- signal
100
+ headers: { 'content-type': 'application/json', origin: EXTENSION_ORIGIN },
101
+ body: JSON.stringify({ extensionVersion: 'test', ...(capabilities ? { capabilities } : {}) }),
102
+ signal,
43
103
  });
44
- const data = await r.json();
104
+ const data = (await r.json());
45
105
  if (data.job && respond) {
46
106
  const result = respond(data.job);
47
107
  if (result) {
48
108
  await fetch(`http://127.0.0.1:${port}/results/${encodeURIComponent(data.job.jobId)}`, {
49
109
  method: 'POST',
50
- headers: {
51
- 'content-type': 'application/json',
52
- origin: EXTENSION_ORIGIN
53
- },
54
- body: JSON.stringify(result)
110
+ headers: { 'content-type': 'application/json', origin: EXTENSION_ORIGIN },
111
+ body: JSON.stringify(result),
55
112
  });
56
113
  }
57
114
  }
58
115
  return data;
59
116
  }
60
117
  async function heartbeat(port) {
61
- await fetch(`http://127.0.0.1:${port}/health`, {
62
- headers: {
63
- origin: EXTENSION_ORIGIN
64
- }
65
- });
118
+ // 带扩展 Origin(与浏览器扩展 SW fetch 同形;无 Origin 的 /health 只是诊断,不记心跳)
119
+ await fetch(`http://127.0.0.1:${port}/health`, { headers: { origin: EXTENSION_ORIGIN } });
66
120
  }
67
121
  async function waitForParkedCount(port, expected, timeoutMs = 1_000) {
68
122
  const deadline = Date.now() + timeoutMs;
69
- while(Date.now() < deadline){
123
+ while (Date.now() < deadline) {
70
124
  const r = await fetch(`http://127.0.0.1:${port}/status`);
71
- const body = await r.json();
72
- if (body.parked === expected) return;
73
- await new Promise((resolve)=>setTimeout(resolve, 10));
125
+ const body = (await r.json());
126
+ if (body.parked === expected)
127
+ return;
128
+ await new Promise((resolve) => setTimeout(resolve, 10));
74
129
  }
75
130
  throw new Error(`/status 未在 ${timeoutMs}ms 内报告 parked=${expected}`);
76
131
  }
77
132
  async function assertParkedClientDoesNotPinHost() {
78
133
  const child = fork(UNREF_CHILD, [], {
79
134
  execArgv: process.execArgv,
80
- stdio: [
81
- 'ignore',
82
- 'ignore',
83
- 'pipe',
84
- 'ipc'
85
- ]
135
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
86
136
  });
87
137
  let stderr = '';
88
- child.stderr?.on('data', (chunk)=>{
89
- stderr += chunk.toString('utf8');
90
- });
138
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
91
139
  let parkedRequest = null;
92
140
  try {
93
- const port = await new Promise((resolve, reject)=>{
94
- const timer = setTimeout(()=>reject(new Error('子进程扩展桥未在 2s 内监听')), 2_000);
95
- child.once('message', (message)=>{
141
+ const port = await new Promise((resolve, reject) => {
142
+ const timer = setTimeout(() => reject(new Error('子进程扩展桥未在 2s 内监听')), 2_000);
143
+ child.once('message', (message) => {
96
144
  clearTimeout(timer);
97
145
  const data = message;
98
- if (data.kind === 'listening' && typeof data.port === 'number') resolve(data.port);
99
- else reject(new Error(data.summary ?? `子进程消息异常:${JSON.stringify(data)}`));
146
+ if (data.kind === 'listening' && typeof data.port === 'number')
147
+ resolve(data.port);
148
+ else
149
+ reject(new Error(data.summary ?? `子进程消息异常:${JSON.stringify(data)}`));
100
150
  });
101
- child.once('exit', (code, signal)=>{
151
+ child.once('exit', (code, signal) => {
102
152
  clearTimeout(timer);
103
153
  reject(new Error(`子进程过早退出 code=${code} signal=${signal} ${stderr}`));
104
154
  });
105
155
  });
106
- parkedRequest = claimOnce(port, null).catch(()=>({
107
- job: null
108
- }));
156
+ parkedRequest = claimOnce(port, null).catch(() => ({ job: null }));
109
157
  await waitForParkedCount(port, 1);
110
- const exitP = new Promise((resolve, reject)=>{
111
- const timer = setTimeout(()=>{
158
+ const exitP = new Promise((resolve, reject) => {
159
+ const timer = setTimeout(() => {
112
160
  child.kill();
113
161
  reject(new Error('默认桥被 parked /jobs 长轮询钉住超过 1500ms'));
114
162
  }, 1_500);
115
- child.once('exit', (code, signal)=>{
163
+ child.once('exit', (code, signal) => {
116
164
  clearTimeout(timer);
117
- resolve({
118
- code,
119
- signal
120
- });
165
+ resolve({ code, signal });
121
166
  });
122
167
  });
123
- child.send({
124
- kind: 'release'
125
- });
168
+ child.send({ kind: 'release' });
126
169
  const exited = await exitP;
127
170
  assert.equal(exited.code, 0, `子进程应自然退出:${stderr}`);
128
171
  assert.equal(exited.signal, null);
129
- } finally{
130
- if (child.exitCode === null && child.signalCode === null) child.kill();
131
- if (parkedRequest) await parkedRequest;
172
+ }
173
+ finally {
174
+ if (child.exitCode === null && child.signalCode === null)
175
+ child.kill();
176
+ if (parkedRequest)
177
+ await parkedRequest;
132
178
  }
133
179
  }
134
- const stripRe = (s)=>s.replace(/\\/g, '');
180
+ const stripRe = (s) => s.replace(/\\/g, '');
135
181
  async function main() {
136
182
  console.log('§38 扩展传输车道合同(extension bridge,全离线)');
183
+ /* ---------- ① manifest 合同与防漂移 ---------- */
137
184
  const manifest = JSON.parse(read('manifest.json'));
138
- const derivedId = createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest().subarray(0, 16).toString('hex').replace(/[0-9a-f]/g, (c)=>'abcdefghijklmnop'[Number.parseInt(c, 16)]);
139
- await check('manifest key 派生扩展 ID = Node 侧固定 EXTENSION_ID(unpacked 通道 origin 白名单锚点)', ()=>{
185
+ const derivedId = createHash('sha256')
186
+ .update(Buffer.from(manifest.key, 'base64'))
187
+ .digest()
188
+ .subarray(0, 16)
189
+ .toString('hex')
190
+ .replace(/[0-9a-f]/g, (c) => 'abcdefghijklmnop'[Number.parseInt(c, 16)]);
191
+ await check('manifest key 派生扩展 ID = Node 侧固定 EXTENSION_ID(unpacked 通道 origin 白名单锚点)', () => {
140
192
  assert.equal(derivedId, EXTENSION_ID);
141
193
  assert.equal(EXTENSION_ORIGIN, `chrome-extension://${EXTENSION_ID}`);
142
194
  });
143
- await check('双通道白名单:商店版 ID(CWS 重签,不认 manifest key)与 unpacked 固定 ID 同信,商店 URL 锚定 item ID', ()=>{
195
+ await check('双通道白名单:商店版 ID(CWS 重签,不认 manifest key)与 unpacked 固定 ID 同信,商店 URL 锚定 item ID', () => {
144
196
  assert.equal(EXTENSION_ORIGIN_STORE, `chrome-extension://${EXTENSION_ID_STORE}`);
145
197
  assert.ok(EXTENSION_ORIGINS.includes(EXTENSION_ORIGIN));
146
198
  assert.ok(EXTENSION_ORIGINS.includes(EXTENSION_ORIGIN_STORE));
147
199
  assert.equal(EXTENSION_ORIGINS.length, 2);
148
200
  assert.ok(EXTENSION_STORE_URL.includes(EXTENSION_ID_STORE));
149
201
  });
150
- await check('manifest 合同:MV3 + 最小权限(cookies/alarms;无 debugger/tabs)', ()=>{
202
+ await check('manifest 合同:MV3 + 最小权限(cookies/alarms;无 storage/options_page/tabs;founder 2026-09-11 不向员工暴露任何配置面)', () => {
151
203
  assert.equal(manifest.manifest_version, 3);
152
- assert.deepEqual([
153
- ...manifest.permissions
154
- ].sort(), [
155
- 'alarms',
156
- 'cookies'
157
- ]);
158
- });
159
- const manifestPorts = manifest.host_permissions.map((p)=>/127\.0\.0\.1:(\d+)/.exec(p)?.[1]).filter((v)=>v != null).map(Number).sort((a, b)=>a - b);
160
- await check('防漂移:桥端口池(Node BRIDGE_PORTS)= manifest host_permissions 回环面(+ ctrip 星域)', ()=>{
161
- assert.deepEqual(manifestPorts, [
162
- ...BRIDGE_PORTS
163
- ].sort((a, b)=>a - b));
204
+ assert.deepEqual([...manifest.permissions].sort(), ['alarms', 'cookies']);
205
+ assert.ok(!manifest.permissions.includes('storage'), '不得用 storage 持久化配置(配置由 portal 静默派发)');
206
+ assert.equal(manifest.options_page, undefined, '不得挂 options 页(employee-facing UI 全禁)');
207
+ assert.ok(Array.isArray(manifest.optional_host_permissions), '应声明 optional_host_permissions(远程 gotry-backend 域按需授权)');
208
+ });
209
+ const manifestPorts = manifest.host_permissions
210
+ .map((p) => /127\.0\.0\.1:(\d+)/.exec(p)?.[1])
211
+ .filter((v) => v != null)
212
+ .map(Number)
213
+ .sort((a, b) => a - b);
214
+ await check('防漂移:桥端口池(Node BRIDGE_PORTS)= manifest host_permissions 回环面(+ ctrip/dida 星域;2026-09-11 保留站点域:founder 不接受单凭 chrome.cookies API 推断,显式授权链可审计)', () => {
215
+ assert.deepEqual(manifestPorts, [...BRIDGE_PORTS].sort((a, b) => a - b));
164
216
  assert.ok(manifest.host_permissions.includes('https://*.ctrip.com/*'));
217
+ assert.ok(manifest.host_permissions.includes('https://*.dida.com/*'));
218
+ assert.ok(manifest.host_permissions.includes('https://dida.com/*'));
165
219
  });
166
- await check('防漂移:content_scripts 双 world 挂 ctrip 双站+12306(MAIN 嗅探 + ISOLATED 桥;2026-09-03 酒/火实装)', ()=>{
220
+ await check('防漂移:content_scripts 双 world 挂 ctrip 双站+12306+dida(MAIN 嗅探 + ISOLATED 桥;2026-09-03 酒/火实装,2026-09-09 dida 实装)', () => {
167
221
  assert.equal(manifest.content_scripts.length, 2);
168
- for (const cs of manifest.content_scripts){
169
- assert.deepEqual(cs.matches, [
170
- 'https://flights.ctrip.com/*',
171
- `https://${HOTEL_SITE_HOST}/*`,
172
- `https://${TRAIN_SITE_HOST}/*`,
173
- 'https://www.12306.cn/*'
174
- ]);
222
+ for (const cs of manifest.content_scripts) {
223
+ assert.deepEqual(cs.matches, ['https://flights.ctrip.com/*', `https://${HOTEL_SITE_HOST}/*`, `https://${TRAIN_SITE_HOST}/*`, 'https://www.12306.cn/*', `https://${DIDA_SITE_HOST}/*`]);
175
224
  assert.equal(cs.run_at, 'document_start');
176
225
  }
177
- const worlds = manifest.content_scripts.map((cs)=>cs.world ?? 'ISOLATED').sort();
178
- assert.deepEqual(worlds, [
179
- 'ISOLATED',
180
- 'MAIN'
181
- ]);
182
- assert.deepEqual(manifest.content_scripts.map((cs)=>cs.js[0]).sort(), [
183
- 'content-bridge.js',
184
- 'content-main.js'
185
- ]);
226
+ const worlds = manifest.content_scripts.map((cs) => cs.world ?? 'ISOLATED').sort();
227
+ assert.deepEqual(worlds, ['ISOLATED', 'MAIN']);
228
+ assert.deepEqual(manifest.content_scripts.map((cs) => cs.js[0]).sort(), ['content-bridge.js', 'content-main.js']);
186
229
  });
187
230
  const backgroundJs = read('background.js');
188
231
  const contentMainJs = read('content-main.js');
189
232
  const contentBridgeJs = read('content-bridge.js');
190
- await check('版本跟随主版本:manifest version_name = package.json version;version = 四段投影(0.0.1-rc.N 0.0.1.N,founder 2026-09-03 拍板)', ()=>{
233
+ await check('配置交付(2026-09-11 hotelbyte 口径:员工零配置):portal 派发 join ticketSW 静默接收;无 storage/options UI', () => {
234
+ assert.ok(!backgroundJs.includes('chrome.storage'), '背景 SW 不得读 chrome.storage(配置由 portal 派发,非持久化)');
235
+ assert.ok(!backgroundJs.includes('chrome.storage.local.get'), '背景 SW 不得列读 storage.local.gotryRemoteBridge');
236
+ assert.ok(!backgroundJs.includes('chrome.storage.onChanged'), '背景 SW 不得订阅 storage 变更');
237
+ assert.ok(backgroundJs.includes('chrome.runtime.onMessage'), '背景 SW 应订阅 runtime.onMessage 接收 portal 派发');
238
+ assert.ok(backgroundJs.includes('\'gotry-join\''), '背景 SW 应识别 gotry-join 消息(portal content-bridge 派发)');
239
+ assert.ok(backgroundJs.includes('joinTicket'), '背景 SW 应有 joinTicket 模块变量缓存 ticket');
240
+ assert.ok(backgroundJs.includes('isJoinFresh'), '背景 SW 应有 join ticket 有效性校验(过期/token 缺失即弃)');
241
+ assert.ok(contentBridgeJs.includes('__gotryJoinTicket'), 'content-bridge 应在页面里读 window.__gotryJoinTicket');
242
+ assert.ok(contentBridgeJs.includes('type: \'gotry-join\''), 'content-bridge 应把 ticket 转 gotry-join 消息派给 SW');
243
+ assert.ok(contentBridgeJs.includes('chrome.runtime.sendMessage'), 'content-bridge 应用 chrome.runtime.sendMessage 派发');
244
+ assert.ok(!backgroundJs.includes('options.html'), '扩展不得引用 options.html/options.js(employee 零配置)');
245
+ });
246
+ await check('dida multiCollect(2026-09-11,推荐流双接口):背景分类函数 + waitSniffMulti 分桶结算', () => {
247
+ assert.ok(backgroundJs.includes('classifyDidaSniff'), '背景 SW 应抽 dida sniff 分类函数');
248
+ assert.ok(backgroundJs.includes('SearchHomepageRecommendHotels'), '多回包分类应含 hotels 接口');
249
+ assert.ok(backgroundJs.includes('SearchHomepageRecommendPrices'), '多回包分类应含 recommendPrices 接口');
250
+ assert.ok(backgroundJs.includes('waitSniffMulti'), '应有多回包分桶结算版本(双齐即返)');
251
+ assert.ok(backgroundJs.includes('job.multiCollect'), '背景 SW 应按 job.multiCollect 分派单首包/多回包');
252
+ });
253
+ await check('版本跟随主版本:manifest version_name = package.json version;version = 四段投影(0.0.1-rc.N → 0.0.1.N,founder 2026-09-03 拍板)', () => {
191
254
  const pkgVersion = JSON.parse(readFileSync(join(EXT_DIR, '..', 'package.json'), 'utf8')).version;
192
255
  assert.equal(manifest.version_name, pkgVersion, 'version_name 应与 gotry 主版本逐字一致');
193
256
  const m = /^(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$/.exec(pkgVersion);
@@ -195,260 +258,212 @@ async function main() {
195
258
  assert.equal(manifest.version, `${m[1]}.${m[2]}.${m[3]}.${m[4]}`, 'version 应为主版本的四段投影(Chrome manifest 只收点分整数)');
196
259
  assert.equal(manifest.content_scripts.length, 2);
197
260
  });
198
- await check('防漂移:票据 cookie 名单(Node LOGIN_COOKIE_NAMES)= 扩展 SITES.ticketNames', ()=>{
199
- for (const name of LOGIN_COOKIE_NAMES){
261
+ await check('防漂移:票据 cookie 名单(Node LOGIN_COOKIE_NAMES)= 扩展 SITES.ticketNames', () => {
262
+ for (const name of LOGIN_COOKIE_NAMES) {
200
263
  assert.ok(backgroundJs.includes(`'${name}'`), `background.js 缺票据名 ${name}`);
201
264
  }
202
265
  assert.ok(backgroundJs.includes("domain: 'ctrip.com'"), 'background.js 站点域应与 LOGIN_TARGETS.domain 对账');
203
266
  });
204
- await check('防漂移:NETWORK_HINTS + HOTEL_NETWORK_HINTS(Node)= content-main 嗅探面(MAIN-world)', ()=>{
205
- for (const hint of NETWORK_HINTS){
267
+ await check('防漂移:NETWORK_HINTS + HOTEL_NETWORK_HINTS(Node)= content-main 嗅探面(MAIN-world)', () => {
268
+ for (const hint of NETWORK_HINTS) {
206
269
  assert.ok(stripRe(contentMainJs).includes(stripRe(hint.source)), `content-main.js 缺 hint ${hint.source}`);
207
270
  }
208
- for (const hint of HOTEL_NETWORK_HINTS){
271
+ for (const hint of HOTEL_NETWORK_HINTS) {
209
272
  assert.ok(stripRe(contentMainJs).includes(stripRe(hint.source)), `content-main.js 缺酒店 hint ${hint.source}`);
210
273
  }
211
- for (const hint of TRAIN_NETWORK_HINTS){
274
+ for (const hint of TRAIN_NETWORK_HINTS) {
212
275
  assert.ok(stripRe(contentMainJs).includes(stripRe(hint.source)), `content-main.js 缺火车 hint ${hint.source}`);
213
276
  }
214
277
  assert.ok(contentMainJs.includes('12306'), 'content-main.js 应感知 12306 页域(火车嗅探作用域)');
215
278
  assert.ok(contentMainJs.includes('hotels\\.ctrip\\.com'), 'content-main.js 应感知酒店页域(形状嗅探兜底的作用域)');
216
279
  });
217
- await check('防漂移:酒店形状签名(Node looksLikeHotelListBody 同款)= content-main HOTEL_BODY_SIG_RE', ()=>{
280
+ await check('防漂移:酒店形状签名(Node looksLikeHotelListBody 同款)= content-main HOTEL_BODY_SIG_RE', () => {
218
281
  assert.ok(stripRe(contentMainJs).includes('"hotelList"|"hotelMatchInfos"|"hotelName"'), '形状嗅探签名两侧必须逐字一致');
219
282
  });
220
- await check('防漂移:检索/登录 URL 面(background per-site 白名单 flights+hotels;ISOLATED 桥转发两类事件)', ()=>{
283
+ await check('防漂移:检索/登录 URL 面(background per-site 白名单 flights+hotels;ISOLATED 桥转发两类事件)', () => {
221
284
  assert.ok(backgroundJs.includes('https://flights.ctrip.com/'), 'background 应含机票检索白名单');
222
285
  assert.ok(backgroundJs.includes(`https://${HOTEL_SITE_HOST}/`), 'background 应含酒店检索白名单(2026-09-03 实装)');
223
286
  assert.ok(backgroundJs.includes('https://kyfw.12306.cn/'), 'background 应含火车检索白名单(2026-09-03 实装)');
224
287
  assert.ok(backgroundJs.includes("'ctrip-hotel'"), 'background SITES 应注册 ctrip-hotel');
225
288
  assert.ok(backgroundJs.includes("'train-12306'"), 'background SITES 应注册 train-12306');
289
+ assert.ok(backgroundJs.includes("'dida-portal'"), 'background SITES 应注册 dida-portal(2026-09-09 实装)');
290
+ assert.ok(backgroundJs.includes('https://portal.dida.com/'), 'background 应含 dida 检索白名单');
226
291
  assert.ok(contentMainJs.includes('gotry-ctrip-sniff'));
227
292
  assert.ok(contentBridgeJs.includes('gotry-ctrip-sniff'));
293
+ assert.ok(contentBridgeJs.includes('url: d.url'), 'content bridge 应转发嗅探响应 URL');
294
+ assert.ok(backgroundJs.includes('url: String(msg.url ??'), 'background 应保留嗅探响应 URL');
295
+ assert.ok(contentMainJs.includes('typeof res.url === \'string\''), 'content-main fetch 应读取浏览器 response.url');
296
+ assert.ok(contentMainJs.includes('typeof xhr.responseURL === \'string\''), 'content-main XHR 应读取浏览器 responseURL');
228
297
  assert.ok(contentBridgeJs.includes('gotry-page'));
229
298
  });
230
- await check('物理只读形态:扩展全部 fetch 只指向桥回环端口;不用 chrome.debugger', ()=>{
231
- for (const [name, src] of [
232
- [
233
- 'background',
234
- backgroundJs
235
- ],
236
- [
237
- 'content-main',
238
- contentMainJs
239
- ],
240
- [
241
- 'content-bridge',
242
- contentBridgeJs
243
- ]
244
- ]){
245
- const loopbackFetches = src.match(/fetch\(`?http:\/\/127\.0\.0\.1/g) ?? [];
299
+ await check('相对 12306 输入 → fetch/XHR 均转发同一响应的绝对 URL 与 body', async () => {
300
+ const events = await contentMainRelativeResponseUrlProof(contentMainJs);
301
+ const expectedUrl = 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM';
302
+ assert.deepEqual(events, [
303
+ { url: expectedUrl, body: '{"data":{"result":[]}}' },
304
+ { url: expectedUrl, body: '{"data":{"result":[]}}' },
305
+ ]);
306
+ });
307
+ await check('防漂移(Dida):DIDA_NETWORK_HINTS(Node)= content-main 嗅探面;票据名= background SITES;manifest 覆盖 portal.dida.com', () => {
308
+ for (const hint of DIDA_NETWORK_HINTS) {
309
+ assert.ok(stripRe(contentMainJs).includes(stripRe(hint.source)), `content-main.js 缺 dida hint ${hint.source}`);
310
+ }
311
+ assert.ok(contentMainJs.includes('portal\\.dida\\.com'), 'content-main.js 应感知 dida 页域');
312
+ for (const name of DIDA_LOGIN_COOKIE_NAMES) {
313
+ assert.ok(backgroundJs.includes(`'${name}'`), `background.js 缺 dida 票据名 ${name}`);
314
+ }
315
+ assert.ok(backgroundJs.includes("domain: 'dida.com'"), 'background.js dida 站点域应与 LOGIN_TARGETS.domain 对账');
316
+ const manifestAny = JSON.parse(readFileSync(join(EXT_DIR, 'manifest.json'), 'utf8'));
317
+ assert.ok(manifestAny.host_permissions.includes('https://*.dida.com/*'), 'manifest host_permissions 应含 dida 域');
318
+ assert.ok(manifestAny.content_scripts.every((b) => b.matches.includes(`https://${DIDA_SITE_HOST}/*`)), 'manifest 两组 content_scripts 均应注入 portal.dida.com');
319
+ assert.ok(stripRe(contentMainJs).includes('"HotelPriceList"|"RatePlanList"'), 'dida 形状签名两侧必须逐字一致');
320
+ });
321
+ await check('物理只读形态:扩展全部 fetch 只指向桥(loopback / 远程桥 URL / bridgeBase() / joinTicket.bridgeUrl 含 .replace 派生 / 变量别名 url);不用 chrome.debugger;站点域一律 zero fetch', () => {
322
+ for (const [name, src] of [['background', backgroundJs], ['content-main', contentMainJs], ['content-bridge', contentBridgeJs]]) {
323
+ // joinTicket.bridgeUrl 分支用 [^}]* 而非 [^)]*:允许同一变量的 .replace(...) 等纯派生表达式(如去尾斜杠),
324
+ // 派生链一旦写出嵌套 `${}` 或新目标即不再命中——只放宽对同一变量的匹配,不放宽目标集合(fail-closed)。
325
+ const bridgeFetches = src.match(/fetch\(`?(?:http:\/\/127\.0\.0\.1|\$\{bridgeBase\(\)\}|\$\{remoteBridge\.baseUrl\}|\$\{joinTicket\.bridgeUrl[^}]*\})|\bfetch\(\s*url\b/g) ?? [];
246
326
  const allFetches = src.match(/fetch\(/g) ?? [];
247
- assert.equal(loopbackFetches.length, allFetches.length, `${name}: fetch 必须只指向桥回环端口(扩展零写行为的代码面证据)`);
327
+ assert.equal(bridgeFetches.length, allFetches.length, `${name}: fetch 必须只指向桥——扩展零写行为的代码面证据`);
248
328
  }
249
329
  assert.ok(!backgroundJs.includes('chrome.debugger'), '扩展不得使用 chrome.debugger(警告条/调试面)');
250
330
  });
251
- const b = await mustBridge([
252
- 0
253
- ]);
331
+ /* ---------- 桥行为(临时端口 + 假扩展客户端) ---------- */
332
+ const b = await mustBridge([0]);
254
333
  const port = b.port;
255
- await check('桥只绑回环临时端口;/health 无 Origin 可诊断', async ()=>{
334
+ await check('桥只绑回环临时端口;/health 无 Origin 可诊断', async () => {
256
335
  assert.ok(port > 0);
257
336
  const r = await fetch(`http://127.0.0.1:${port}/health`);
258
- const body = await r.json();
337
+ const body = (await r.json());
259
338
  assert.equal(r.status, 200);
260
339
  assert.equal(body.ok, true);
261
340
  assert.equal(body.service, 'gotry-session-bridge');
262
341
  });
263
342
  await check('默认桥:parked 扩展长轮询不得钉住宿主 CLI 进程', assertParkedClientDoesNotPinHost);
264
- await check('origin 白名单:邪恶源(网页跨域必带)POST /jobs 与 /results 一律 403', async ()=>{
265
- for (const path of [
266
- '/jobs',
267
- '/results/whatever'
268
- ]){
343
+ await check('origin 白名单:邪恶源(网页跨域必带)POST /jobs 与 /results 一律 403', async () => {
344
+ for (const path of ['/jobs', '/results/whatever']) {
269
345
  const r = await fetch(`http://127.0.0.1:${port}${path}`, {
270
346
  method: 'POST',
271
- headers: {
272
- 'content-type': 'application/json',
273
- origin: 'https://evil.example'
274
- },
275
- body: '{}'
347
+ headers: { 'content-type': 'application/json', origin: 'https://evil.example' },
348
+ body: '{}',
276
349
  });
277
350
  assert.equal(r.status, 403, path);
278
351
  }
279
352
  });
280
- await check('origin 白名单:商店版扩展源(CWS 重签 ID,2026-09-02 上架)POST 放行且计入心跳', async ()=>{
281
- const storeLane = await mustBridge([
282
- 0
283
- ]);
353
+ await check('origin 白名单:商店版扩展源(CWS 重签 ID,2026-09-02 上架)POST 放行且计入心跳', async () => {
354
+ const storeLane = await mustBridge([0]);
284
355
  try {
285
356
  assert.equal(storeLane.extensionConnected(), false);
286
357
  const r = await fetch(`http://127.0.0.1:${storeLane.port}/results/whatever`, {
287
358
  method: 'POST',
288
- headers: {
289
- 'content-type': 'application/json',
290
- origin: EXTENSION_ORIGIN_STORE
291
- },
292
- body: '{}'
359
+ headers: { 'content-type': 'application/json', origin: EXTENSION_ORIGIN_STORE },
360
+ body: '{}',
293
361
  });
294
362
  assert.equal(r.status, 200, '商店版扩展源不得吃 403(否则商店通道全断)');
295
363
  assert.equal(storeLane.extensionConnected(), true, '白名单源的任何请求都刷新心跳');
296
- } finally{
364
+ }
365
+ finally {
297
366
  await storeLane.close();
298
367
  }
299
368
  });
300
- await check('心跳判定:初始未连接;合法请求(health/长轮询)后视为在线', async ()=>{
369
+ await check('心跳判定:初始未连接;合法请求(health/长轮询)后视为在线', async () => {
301
370
  assert.equal(b.extensionConnected(), false);
302
371
  await heartbeat(port);
303
372
  assert.equal(b.extensionConnected(), true);
304
373
  });
305
- await check('提交-回包闭环:cookie-names job 由假扩展取活并回包,submit 原样收果', async ()=>{
306
- const submitP = b.submit({
307
- kind: 'cookie-names',
308
- site: 'ctrip-flight',
309
- timeoutMs: 5_000
310
- }, {
311
- timeoutMs: 5_000
312
- });
313
- const claimed = await claimOnce(port, (job)=>{
374
+ await check('提交-回包闭环:cookie-names job 由假扩展取活并回包,submit 原样收果', async () => {
375
+ const submitP = b.submit({ kind: 'cookie-names', site: 'ctrip-flight', timeoutMs: 5_000 }, { timeoutMs: 5_000 });
376
+ const claimed = await claimOnce(port, (job) => {
314
377
  assert.equal(job.kind, 'cookie-names');
315
378
  assert.equal(job.site, 'ctrip-flight');
316
- return {
317
- ok: true,
318
- kind: 'cookie-names',
319
- names: [
320
- 'cticket',
321
- 'uid'
322
- ]
323
- };
379
+ return { ok: true, kind: 'cookie-names', names: ['cticket', 'uid'] };
324
380
  });
325
381
  assert.ok(claimed.job);
326
382
  const outcome = await submitP;
327
383
  assert.ok(outcome.ok);
328
- assert.deepEqual(outcome.ok ? outcome.result.names : [], [
329
- 'cticket',
330
- 'uid'
331
- ]);
384
+ assert.deepEqual(outcome.ok ? outcome.result.names : [], ['cticket', 'uid']);
332
385
  });
333
- await check('cookie 红线:扩展代码只上报名(c.name 映射),值不进任何结果对象', ()=>{
386
+ await check('cookie 红线:扩展代码只上报名(c.name 映射),值不进任何结果对象', () => {
334
387
  assert.ok(backgroundJs.includes('.map((c) => c.name)'));
335
388
  assert.ok(!/\.value/.test(backgroundJs), 'background.js 不得触碰 cookie.value');
336
389
  });
337
- await check('长轮询幂等:并发取活只有一个客户端领到 job;后到者空手(快速中止,不挂进程)', async ()=>{
390
+ await check('长轮询幂等:并发取活只有一个客户端领到 job;后到者空手(快速中止,不挂进程)', async () => {
338
391
  const firstAc = new AbortController();
339
392
  const secondAc = new AbortController();
340
- const firstP = claimOnce(port, ()=>({
341
- ok: true,
342
- kind: 'cookie-names',
343
- names: [
344
- 'cticket'
345
- ]
346
- }), firstAc.signal).catch(()=>({
347
- job: null
348
- }));
393
+ const firstP = claimOnce(port, () => ({ ok: true, kind: 'cookie-names', names: ['cticket'] }), firstAc.signal).catch(() => ({ job: null }));
349
394
  let secondP = null;
350
395
  try {
351
396
  await waitForParkedCount(port, 1);
352
- secondP = claimOnce(port, null, secondAc.signal).catch(()=>({
353
- job: null
354
- }));
397
+ secondP = claimOnce(port, null, secondAc.signal).catch(() => ({ job: null }));
355
398
  await waitForParkedCount(port, 2);
356
- const submitP = b.submit({
357
- kind: 'cookie-names',
358
- site: 'ctrip-flight',
359
- timeoutMs: 5_000
360
- }, {
361
- timeoutMs: 5_000
362
- });
399
+ const submitP = b.submit({ kind: 'cookie-names', site: 'ctrip-flight', timeoutMs: 5_000 }, { timeoutMs: 5_000 });
363
400
  const first = await firstP;
364
401
  assert.ok(first.job, '先进入 parked 队列的取活者必须领到 job');
365
402
  const outcome = await submitP;
366
403
  assert.ok(outcome.ok);
367
- } finally{
404
+ }
405
+ finally {
368
406
  firstAc.abort();
369
407
  secondAc.abort();
370
- if (secondP) await secondP;
408
+ if (secondP)
409
+ await secondP;
371
410
  }
372
411
  });
373
- await check('超时语义:扩展在线但不回包 → timeout(降级 error,不伪装成功)', async ()=>{
374
- const outcome = await b.submit({
375
- kind: 'search',
376
- site: 'ctrip-flight',
377
- url: 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-12-01',
378
- timeoutMs: 300
379
- }, {
380
- timeoutMs: 300
381
- });
412
+ await check('超时语义:扩展在线但不回包 → timeout(降级 error,不伪装成功)', async () => {
413
+ const outcome = await b.submit({ kind: 'search', site: 'ctrip-flight', url: 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-12-01', timeoutMs: 300 }, { timeoutMs: 300 });
382
414
  assert.ok(!outcome.ok);
383
415
  assert.equal(outcome.ok ? null : outcome.reason, 'timeout');
384
416
  });
385
- await check('扩展未连接:有界等待后 extension-not-connected(用户门 no-spend,不空耗)', async ()=>{
386
- const fresh = await mustBridge([
387
- 0
388
- ]);
417
+ await check('扩展未连接:有界等待后 extension-not-connected(用户门 no-spend,不空耗)', async () => {
418
+ const fresh = await mustBridge([0]);
389
419
  try {
390
- const outcome = await fresh.submit({
391
- kind: 'cookie-names',
392
- site: 'ctrip-flight'
393
- }, {
394
- extensionWaitMs: 400,
395
- timeoutMs: 2_000
396
- });
420
+ const outcome = await fresh.submit({ kind: 'cookie-names', site: 'ctrip-flight' }, { extensionWaitMs: 400, timeoutMs: 2_000 });
397
421
  assert.ok(!outcome.ok);
398
422
  assert.equal(outcome.ok ? null : outcome.reason, 'extension-not-connected');
399
- } finally{
423
+ }
424
+ finally {
400
425
  await fresh.close();
401
426
  }
402
427
  });
403
- await check('close:桥关闭后排队任务以 bridge-unavailable 结算(零花费)', async ()=>{
404
- const tmp = await mustBridge([
405
- 0
406
- ]);
428
+ await check('close:桥关闭后排队任务以 bridge-unavailable 结算(零花费)', async () => {
429
+ const tmp = await mustBridge([0]);
407
430
  await heartbeat(tmp.port);
408
- const submitP = tmp.submit({
409
- kind: 'cookie-names',
410
- site: 'ctrip-flight',
411
- timeoutMs: 30_000
412
- }, {
413
- timeoutMs: 30_000
414
- });
431
+ const submitP = tmp.submit({ kind: 'cookie-names', site: 'ctrip-flight', timeoutMs: 30_000 }, { timeoutMs: 30_000 });
415
432
  await tmp.close();
416
433
  const outcome = await submitP;
417
434
  assert.ok(!outcome.ok);
418
435
  assert.equal(outcome.ok ? null : outcome.reason, 'bridge-unavailable');
419
436
  });
420
- await check('classifyBridgeFailure:只有 extension-not-connected 是用户门(needs-extension),其余 error', ()=>{
437
+ /* ---------- 车道语义 ---------- */
438
+ await check('classifyBridgeFailure:只有 extension-not-connected 是用户门(needs-extension),其余 error', () => {
421
439
  assert.equal(classifyBridgeFailure('extension-not-connected'), 'needs-extension');
422
440
  assert.equal(classifyBridgeFailure('bridge-unavailable'), 'error');
423
441
  assert.equal(classifyBridgeFailure('timeout'), 'error');
424
442
  assert.equal(classifyBridgeFailure('job-error'), 'error');
425
443
  });
426
444
  const savedTransportEnv = process.env.GOTRY_SESSION_TRANSPORT;
427
- const restoreTransportEnv = ()=>{
428
- if (savedTransportEnv === undefined) delete process.env.GOTRY_SESSION_TRANSPORT;
429
- else process.env.GOTRY_SESSION_TRANSPORT = savedTransportEnv;
445
+ const restoreTransportEnv = () => {
446
+ if (savedTransportEnv === undefined)
447
+ delete process.env.GOTRY_SESSION_TRANSPORT;
448
+ else
449
+ process.env.GOTRY_SESSION_TRANSPORT = savedTransportEnv;
430
450
  };
431
451
  try {
432
452
  delete process.env.GOTRY_SESSION_TRANSPORT;
433
- await check('resolveTransportMode:扩展默认;cdp 须显式 opt-in;profileDir=persistent(测试)', ()=>{
453
+ await check('resolveTransportMode:扩展默认;cdp 须显式 opt-in;profileDir=persistent(测试)', () => {
434
454
  assert.equal(resolveTransportMode(), 'extension');
435
455
  process.env.GOTRY_SESSION_TRANSPORT = 'cdp';
436
456
  assert.equal(resolveTransportMode(), 'cdp');
437
457
  assert.equal(resolveTransportMode('/tmp/isolated-profile'), 'persistent');
438
458
  });
439
- } finally{
459
+ }
460
+ finally {
440
461
  restoreTransportEnv();
441
462
  }
442
463
  const auditDir = mkdtempSync(join(tmpdir(), 'gotry-ext-audit-'));
443
464
  const auditPath = join(auditDir, 'session-incidents.jsonl');
444
- appendExtensionAudit(auditPath, {
445
- kind: 'extension-session-job',
446
- site: 'ctrip-flight',
447
- url: 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-12-01',
448
- jobId: 'search',
449
- result: 'body 1024B'
450
- });
451
- await check('审计:扩展 job 落 session-incidents 同款 JSONL(kind 区分,url 截断)', ()=>{
465
+ appendExtensionAudit(auditPath, { kind: 'extension-session-job', site: 'ctrip-flight', url: 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-12-01', jobId: 'search', result: 'body 1024B' });
466
+ await check('审计:扩展 job 落 session-incidents 同款 JSONL(kind 区分,url 截断)', () => {
452
467
  const lines = readFileSync(auditPath, 'utf8').trim().split('\n');
453
468
  assert.equal(lines.length, 1);
454
469
  const entry = JSON.parse(lines[0]);
@@ -456,123 +471,73 @@ async function main() {
456
471
  assert.equal(entry.site, 'ctrip-flight');
457
472
  assert.ok(entry.url.length <= 400);
458
473
  });
459
- rmSync(auditDir, {
460
- recursive: true,
461
- force: true
462
- });
463
- await check('车道闭环(注入临时桥+假扩展):cookie-names 只吐名字;search job 回传正文;登录快路径零交互', async ()=>{
464
- const lane = await mustBridge([
465
- 0
466
- ]);
474
+ rmSync(auditDir, { recursive: true, force: true });
475
+ await check('车道闭环(注入临时桥+假扩展):cookie-names 只吐名字;search job 回传正文;登录快路径零交互', async () => {
476
+ const lane = await mustBridge([0]);
467
477
  __setSessionBridgeForTest(lane);
468
478
  try {
469
479
  let done = false;
470
- const claimLoop = (async ()=>{
471
- while(!done){
480
+ const claimLoop = (async () => {
481
+ while (!done) {
472
482
  const ac = new AbortController();
473
- const bail = setTimeout(()=>ac.abort(), 2_000);
483
+ const bail = setTimeout(() => ac.abort(), 2_000);
474
484
  try {
475
- await claimOnce(lane.port, (job)=>{
476
- if (job.kind === 'cookie-names') return {
477
- ok: true,
478
- kind: 'cookie-names',
479
- names: [
480
- 'cticket'
481
- ]
482
- };
485
+ await claimOnce(lane.port, (job) => {
486
+ if (job.kind === 'cookie-names')
487
+ return { ok: true, kind: 'cookie-names', names: ['cticket'] };
483
488
  if (job.kind === 'search') {
484
489
  const u = String(job.url);
485
490
  const isHotel = u.startsWith(`https://${HOTEL_SITE_HOST}/`);
486
491
  const isTrain = u.startsWith('https://kyfw.12306.cn/');
487
492
  assert.ok(u.startsWith('https://flights.ctrip.com/') || isHotel || isTrain, `search job 只允许已注册站点域,实际 ${u}`);
493
+ const requestedUrl = new URL(u);
494
+ const requestedDate = requestedUrl.searchParams.get('date') ?? '2026-12-01';
495
+ const wrongRoute = requestedUrl.searchParams.get('fs')?.endsWith(',BJP') === true;
496
+ const wrongDateEmpty = requestedDate === '2026-12-02';
497
+ const trainResponseUrl = wrongRoute
498
+ ? `https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=${requestedDate}&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM`
499
+ : `https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=${wrongDateEmpty ? '2026-12-01' : requestedDate}&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM`;
488
500
  return {
489
501
  ok: true,
490
502
  kind: 'search',
491
- body: isHotel ? JSON.stringify({
492
- data: {
493
- hotelList: [
494
- {
495
- hotelId: 442516,
496
- hotelName: 'Hotel X',
497
- star: 5,
498
- commentScore: 4.7,
499
- priceInfo: {
500
- avgPrice: 680
501
- }
502
- }
503
- ]
504
- }
505
- }) : isTrain ? JSON.stringify({
506
- data: {
507
- result: [
508
- '|预订|24000000G1375|G1375|SHH|KMM|SHH|KMM|07:35|15:27|07:52|Y|yp|x|x|loc|01|02|Y|0|--|--|--|--|--|--|有|--|--|有|有|有|有|--|ex|st|'
509
- ],
510
- map: {
511
- SHH: '上海南',
512
- KMM: '昆明'
513
- }
514
- }
515
- }) : JSON.stringify({
516
- data: {
517
- flightItineraryList: []
518
- }
519
- }),
520
- title: isHotel ? '酒店列表' : isTrain ? '12306 车票预订' : '机票列表'
503
+ body: isHotel
504
+ ? JSON.stringify({ data: { hotelList: [{ hotelId: 442516, hotelName: 'Hotel X', star: 5, commentScore: 4.7, priceInfo: { avgPrice: 680 } }] } })
505
+ : isTrain
506
+ ? JSON.stringify({ data: { result: wrongDateEmpty ? [] : ['|预订|24000000G1375|G1375|SHH|KMM|SHH|KMM|07:35|15:27|07:52|Y|yp|20261201|x|loc|01|02|Y|0|--|--|--|--|--|--|有|--|--|有|有|有|有|--|ex|st|'], map: { SHH: '上海南', KMM: '昆明' } } })
507
+ : JSON.stringify({ data: { flightItineraryList: [] } }),
508
+ url: isTrain ? trainResponseUrl : u,
509
+ title: isHotel ? '酒店列表' : isTrain ? '12306 车票预订' : '机票列表',
521
510
  };
522
511
  }
523
- return {
524
- ok: false,
525
- error: `unexpected kind ${job.kind}`
526
- };
512
+ return { ok: false, error: `unexpected kind ${job.kind}` };
527
513
  }, ac.signal);
528
- } catch {}
514
+ }
515
+ catch { /* park 被中止=常态 */ }
529
516
  clearTimeout(bail);
530
517
  }
531
518
  })();
532
- const cookies = await extensionCookieNames({
533
- site: 'ctrip-flight',
534
- domain: SITE_DOMAIN.replace(/^\./, ''),
535
- ticketNames: LOGIN_COOKIE_NAMES
536
- });
519
+ const cookies = await extensionCookieNames({ site: 'ctrip-flight', domain: SITE_DOMAIN.replace(/^\./, ''), ticketNames: LOGIN_COOKIE_NAMES });
537
520
  assert.ok(cookies.ok);
538
- assert.deepEqual(cookies.ok ? cookies.tickets : [], [
539
- 'cticket'
540
- ]);
541
- const search = await extensionSearchJob({
542
- site: 'ctrip-flight',
543
- url: 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-12-01',
544
- timeoutMs: 3_000
545
- });
521
+ assert.deepEqual(cookies.ok ? cookies.tickets : [], ['cticket']);
522
+ const search = await extensionSearchJob({ site: 'ctrip-flight', url: 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-12-01', timeoutMs: 3_000 });
546
523
  assert.ok(search.ok);
547
524
  assert.equal(search.ok ? search.timedOut : true, false);
548
525
  assert.ok(search.ok ? search.body.includes('flightItineraryList') : false);
549
- const hotelLane = await extensionSearchJob({
550
- site: 'ctrip-hotel',
551
- url: `https://${HOTEL_SITE_HOST}/hotels/list?city=220&checkin=2026-12-01&checkout=2026-12-03`,
552
- timeoutMs: 3_000
553
- });
526
+ assert.equal(search.ok ? search.url : '', 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-12-01', '响应 URL 穿过 background/result/channel');
527
+ // 酒店车道(2026-09-03 实装):per-site 白名单放行 hotels.ctrip.com,嗅探回包走形解析出结构化酒店
528
+ const hotelLane = await extensionSearchJob({ site: 'ctrip-hotel', url: `https://${HOTEL_SITE_HOST}/hotels/list?city=220&checkin=2026-12-01&checkout=2026-12-03`, timeoutMs: 3_000 });
554
529
  assert.ok(hotelLane.ok, '酒店 search job 应放行(per-site 白名单)');
555
530
  assert.ok(hotelLane.ok ? hotelLane.body.includes('hotelList') : false);
556
531
  __resetRateLimiterForTest();
557
- const hotelSession = await sessionHotelSearch({
558
- to: '迪拜',
559
- cityId: 220,
560
- checkIn: '2026-12-01',
561
- checkOut: '2026-12-03',
562
- timeoutMs: 3_000
563
- });
532
+ const hotelSession = await sessionHotelSearch({ to: '迪拜', cityId: 220, checkIn: '2026-12-01', checkOut: '2026-12-03', timeoutMs: 3_000 });
564
533
  assert.equal(hotelSession.verdict, 'hit', `酒店会话检索应 hit,实际 ${hotelSession.verdict}:${hotelSession.error ?? ''}`);
565
534
  assert.ok(hotelSession.evidence.includes('[会话:ctrip-hotel@'), '酒店证据链 [会话:ctrip-hotel@ts]');
566
535
  assert.equal(hotelSession.hotels?.[0]?.name, 'Hotel X');
567
536
  assert.equal(hotelSession.hotels?.[0]?.price, 680);
568
537
  assert.ok(hotelSession.hotels?.[0]?.jumpUrl?.includes('/hotel/442516'), 'jumpUrl 由 hotelId 构造(预订由人在落地页完成)');
538
+ // 火车车道(2026-09-03 实装):公开查询面无登录闸,per-site 白名单放行 kyfw,管道行解析出结构化车次
569
539
  __resetRateLimiterForTest();
570
- const trainSession = await sessionTrainSearch({
571
- from: '上海',
572
- to: '昆明',
573
- date: '2026-12-01',
574
- timeoutMs: 3_000
575
- });
540
+ const trainSession = await sessionTrainSearch({ from: '上海', to: '昆明', date: '2026-12-01', timeoutMs: 3_000 });
576
541
  assert.equal(trainSession.verdict, 'hit', `火车会话检索应 hit,实际 ${trainSession.verdict}:${trainSession.error ?? ''}`);
577
542
  assert.ok(trainSession.evidence.includes('[会话:train-12306@'), '火车证据链 [会话:train-12306@ts]');
578
543
  assert.ok(trainSession.evidence.includes('公开查询面'), '证据链标注公开查询面(无登录闸的诚实口径)');
@@ -580,112 +545,124 @@ async function main() {
580
545
  assert.equal(trainSession.trains?.[0]?.depTime, '07:35');
581
546
  assert.equal(trainSession.trains?.[0]?.durationMin, 472);
582
547
  assert.ok(trainSession.trains?.[0]?.jumpUrl?.includes('kyfw.12306.cn'), 'jumpUrl=查询落地页(人选车完成预订)');
583
- const login = await sessionLogin({
584
- site: 'ctrip-flight'
585
- });
548
+ assert.equal(trainSession.collection?.response.url, 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM', '火车响应 URL 穿过扩展桥并绑定 collection');
549
+ __resetRateLimiterForTest();
550
+ const wrongRoute = await sessionTrainSearch({ from: '北京', to: '上海', date: '2026-12-03', timeoutMs: 3_000 });
551
+ assert.equal(wrongRoute.collection, undefined, 'wrong-route response URL → no collection');
552
+ assert.equal(factsFromSessionTrain({ from: '北京', to: '上海', date: '2026-12-03' }, wrongRoute).length, 0, 'wrong-route collector result → zero facts');
553
+ __resetRateLimiterForTest();
554
+ const wrongDateEmpty = await sessionTrainSearch({ from: '上海', to: '昆明', date: '2026-12-02', timeoutMs: 3_000 });
555
+ assert.equal(wrongDateEmpty.outcome.kind, 'recognized-empty', 'wrong-date seam keeps parsed empty presentation');
556
+ assert.equal(wrongDateEmpty.collection, undefined, 'wrong-date empty response URL → no collection');
557
+ assert.equal(factsFromSessionTrain({ from: '上海', to: '昆明', date: '2026-12-02' }, wrongDateEmpty).length, 0, 'wrong-date empty collector result → zero facts');
558
+ const login = await sessionLogin({ site: 'ctrip-flight' });
586
559
  assert.equal(login.verdict, 'logged-in');
587
- assert.deepEqual(login.tickets, [
588
- 'cticket'
589
- ]);
560
+ assert.deepEqual(login.tickets, ['cticket']);
590
561
  assert.ok(login.evidence.includes('自动检测'));
591
562
  done = true;
592
563
  await claimLoop;
593
- } finally{
564
+ }
565
+ finally {
594
566
  await __resetSessionBridgeForTest();
595
567
  __setSessionBridgeForTest(null);
596
568
  await lane.close();
597
569
  }
598
570
  });
599
- await check('全链 fail-closed:扩展车道桥端口池全占 → sessionFlightSearch verdict=error(环境故障,非用户门)', async ()=>{
571
+ await check('全链 fail-closed:扩展车道桥端口池全占 → sessionFlightSearch verdict=error(环境故障,非用户门)', async () => {
572
+ // 设计契约:只有 extension-not-connected 才是 user gate (needs-extension);
573
+ // 端口池全占是环境故障(并行 gotry 实例/外部进程占端口)→ verdict=error,
574
+ // 不诱导用户去「装扩展」(装也解决不了)。
600
575
  const { createServer } = await import('node:http');
601
576
  const blockers = [];
602
577
  try {
603
- for (const port of [
604
- 8791,
605
- 8792,
606
- 8793,
607
- 8794,
608
- 8795
609
- ]){
578
+ for (const port of [8791, 8792, 8793, 8794, 8795]) {
610
579
  const s = createServer().listen(port, '127.0.0.1');
611
- await new Promise((r)=>s.once('listening', ()=>r()));
612
- blockers.push({
613
- close: ()=>new Promise((r)=>s.close(()=>r()))
614
- });
580
+ await new Promise((r) => s.once('listening', () => r()));
581
+ blockers.push({ close: () => new Promise((r) => s.close(() => r())) });
615
582
  }
616
583
  await __resetSessionBridgeForTest();
617
- const r = await sessionFlightSearch({
618
- from: '上海',
619
- to: '丽江',
620
- date: '2026-12-01'
621
- });
584
+ const r = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2026-12-01' });
622
585
  assert.equal(r.verdict, 'error', `端口池全占应 error(环境故障),实 ${r.verdict}:${r.error}`);
623
586
  assert.ok(r.error?.includes('端口池'), '错误面必须点明端口池环境问题');
624
587
  assert.ok(!r.error?.includes('一次性安装'), 'error 路径不应诱导用户去装扩展');
625
- } finally{
588
+ }
589
+ finally {
626
590
  await __resetSessionBridgeForTest();
627
- for (const b of blockers)await b.close();
591
+ for (const b of blockers)
592
+ await b.close();
628
593
  }
629
594
  });
630
- const waitingRecord = (verdict)=>({
631
- query_id: 'sf-01',
632
- route_segments: [],
633
- journey_type: 'direct',
634
- currency: '',
635
- price: 0,
636
- source: '',
637
- fetched_at: '',
638
- verdict,
639
- latency_ms: 0,
640
- read_guard_blocked: 0
641
- });
642
- await check('双源合同:needs-extension waiting_extension(no_spend_waiting_user,waiting-* 同族)', ()=>{
643
- const e = evaluateDoubleSource({
644
- session: waitingRecord('needs-extension')
645
- });
595
+ const waitingRecord = (verdict) => ({
596
+ query_id: 'sf-01', route_segments: [], journey_type: 'direct', currency: '', price: 0, source: '',
597
+ fetched_at: '', verdict, latency_ms: 0, read_guard_blocked: 0,
598
+ });
599
+ await check('capability 路由:站点 job 只派给声明该站点的轮询者(同机多 Chrome 旧版扩展不毒领新站点 job)', async () => {
600
+ const lane = await mustBridge([0]);
601
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
602
+ try {
603
+ // 旧版扩展(caps 无 dida-portal,模拟商店版)先 parked
604
+ const oldExtP = claimOnce(lane.port, null, undefined, ['ctrip-flight', 'ctrip-hotel', 'train-12306']).catch(() => ({ job: null }));
605
+ await pause(200);
606
+ // dida job 提交:不得派给旧版扩展(它不声明 dida-portal)
607
+ const submitP = lane.submit({ kind: 'cookie-names', site: 'dida-portal' }, { timeoutMs: 3_000 });
608
+ await pause(200);
609
+ const stoleByOld = await Promise.race([oldExtP.then(() => true), pause(400).then(() => false)]);
610
+ assert.ok(!stoleByOld, '旧版扩展(capability 不含 dida-portal)不得领走 dida job');
611
+ // 新版扩展声明 dida-portal → 立即领到
612
+ const newExtGot = await claimOnce(lane.port, (job) => {
613
+ assert.equal(job.kind, 'cookie-names');
614
+ return { ok: true, kind: 'cookie-names', names: ['CN_M_DidaTravel'] };
615
+ }, undefined, ['ctrip-flight', 'ctrip-hotel', 'train-12306', 'dida-portal']);
616
+ assert.ok(newExtGot.job && newExtGot.job.site === 'dida-portal', `声明 dida-portal 的扩展领到 job: ${JSON.stringify(newExtGot.job)}`);
617
+ const outcome = await submitP;
618
+ assert.ok(outcome.ok && outcome.result.names?.includes('CN_M_DidaTravel'), `job 结果正确回传: ${JSON.stringify(outcome)}`);
619
+ // 正向:capability 匹配的 parked 轮询者即时收到新提交的 ctrip job
620
+ const ctripP = lane.submit({ kind: 'cookie-names', site: 'ctrip-flight' }, { timeoutMs: 3_000 });
621
+ await pause(100);
622
+ const parkedOld = await Promise.race([oldExtP, pause(500).then(() => null)]);
623
+ assert.ok(parkedOld && parkedOld.job && parkedOld.job.site === 'ctrip-flight', `旧版扩展领走自己 capability 内的 job: ${JSON.stringify(parkedOld?.job)}`);
624
+ await ctripP.catch(() => ({ ok: false }));
625
+ }
626
+ finally {
627
+ await lane.close();
628
+ }
629
+ });
630
+ await check('双源合同:needs-extension → waiting_extension(no_spend_waiting_user,waiting-* 同族)', () => {
631
+ const e = evaluateDoubleSource({ session: waitingRecord('needs-extension') });
646
632
  assert.equal(e.state, 'waiting_extension');
647
633
  assert.equal(e.quota_disposition, 'no_spend_waiting_user');
648
634
  assert.equal(e.retry_allowed, false);
649
635
  });
650
- await check('needs-extension 自适应文案:本地已落位 → 双通道完整指引(含开发者模式)', ()=>{
651
- const s = needsExtensionSummary({
652
- localInstalled: true
653
- });
636
+ // D-24 自适应文案(issue #117):按本地通道是否落位自动跳过开发者模式/本地通道指引
637
+ await check('needs-extension 自适应文案:本地已落位 → 双通道完整指引(含开发者模式)', () => {
638
+ const s = needsExtensionSummary({ localInstalled: true });
654
639
  assert.match(s, /开发者模式「加载已解压的扩展程序」/, '本地通道在 → 保留开发者模式指引');
655
640
  assert.ok(s.includes(EXTENSION_STORE_URL), '商店 URL 仍在');
656
641
  });
657
- await check('needs-extension 自适应文案:本地未落位(商店版用户/未装)→ 自动跳过 dev-mode 文案', ()=>{
658
- const s = needsExtensionSummary({
659
- localInstalled: false
660
- });
642
+ await check('needs-extension 自适应文案:本地未落位(商店版用户/未装)→ 自动跳过 dev-mode 文案', () => {
643
+ const s = needsExtensionSummary({ localInstalled: false });
661
644
  assert.doesNotMatch(s, /开发者模式/, '不再推开发者模式/本地通道(商店版用户不该被指导 load unpacked)');
662
645
  assert.doesNotMatch(s, /加载已解压/, '不再出现 load unpacked 字样');
663
646
  assert.match(s, /已从 Chrome 商店安装/, '给「已装商店版?」提示(打开 Chrome 即可,无需再装)');
664
647
  assert.match(s, /应用商店一键装/, '未装用户仍得商店一键装指引');
665
648
  });
666
- await check('localExtensionInstalled:HOME 注入可测(manifest 存在=本地通道在)', ()=>{
649
+ await check('localExtensionInstalled:HOME 注入可测(manifest 存在=本地通道在)', () => {
667
650
  const fakeHome = mkdtempSync(join(tmpdir(), 'gotry-ext-home-'));
668
651
  assert.equal(localExtensionInstalled(fakeHome), false, '空 HOME → 本地通道未落位');
669
- mkdirSync(join(fakeHome, '.gotry', 'extension'), {
670
- recursive: true
671
- });
652
+ mkdirSync(join(fakeHome, '.gotry', 'extension'), { recursive: true });
672
653
  writeFileSync(join(fakeHome, '.gotry', 'extension', 'manifest.json'), '{}', 'utf-8');
673
654
  assert.equal(localExtensionInstalled(fakeHome), true, 'manifest 落位 → true');
674
- rmSync(fakeHome, {
675
- recursive: true,
676
- force: true
677
- });
655
+ rmSync(fakeHome, { recursive: true, force: true });
678
656
  });
679
657
  await b.close();
680
658
  console.log(`\n§38 extension bridge: ${passed} 段全绿`);
681
659
  }
682
- main().catch((e)=>{
660
+ main().catch((e) => {
683
661
  console.error('FAIL:', e);
662
+ // best-effort cleanup on early failure:关掉所有未释放的桥
684
663
  try {
685
- __resetSessionBridgeForTest().catch(()=>{});
686
- } catch {}
664
+ __resetSessionBridgeForTest().catch(() => { });
665
+ }
666
+ catch { }
687
667
  process.exit(1);
688
668
  });
689
-
690
-
691
- //# sourceURL=ts/scripts/extension-tests.ts