@danceiny/gotry 0.0.1-rc.8 → 0.2.0-rc.27

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 (506) hide show
  1. package/README.md +172 -217
  2. package/README.zh-CN.md +222 -0
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-booking-copilot.js +53 -0
  5. package/bin/gotry-bootstrap.js +1744 -0
  6. package/bin/gotry-inner.js +733 -83
  7. package/bin/gotry-process-liveness.d.ts +27 -0
  8. package/bin/gotry-process-liveness.js +97 -0
  9. package/bin/gotry-runtime-resolution.d.ts +27 -0
  10. package/bin/gotry-runtime-resolution.js +49 -0
  11. package/bin/gotry.js +1 -1
  12. package/client/client.js +203 -0
  13. package/cordis.gotry-patch.yml +80 -49
  14. package/data/airline-airports.json +39 -0
  15. package/data/stations-12306-verify.json +524 -0
  16. package/dist/capabilities/agent-reach-deep.js +45 -122
  17. package/dist/capabilities/agent-reach.js +101 -146
  18. package/dist/capabilities/anything.js +135 -94
  19. package/dist/capabilities/artifacts.js +370 -0
  20. package/dist/capabilities/channel-health.js +131 -0
  21. package/dist/capabilities/channel-registry.js +256 -0
  22. package/dist/capabilities/doctor.js +546 -0
  23. package/dist/capabilities/effect.js +394 -0
  24. package/dist/capabilities/external-event.js +149 -0
  25. package/dist/capabilities/fact-log.js +55 -0
  26. package/dist/capabilities/flyai-config.js +337 -0
  27. package/dist/capabilities/flyai.js +968 -0
  28. package/dist/capabilities/fx-contract.js +293 -0
  29. package/dist/capabilities/geo-atlas.js +246 -0
  30. package/dist/capabilities/ground-transfer.js +509 -0
  31. package/dist/capabilities/hbcli.js +260 -123
  32. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  33. package/dist/capabilities/incident-log.js +131 -61
  34. package/dist/capabilities/itinerary-artifact.js +190 -0
  35. package/dist/capabilities/itinerary-deck-artifact.js +194 -0
  36. package/dist/capabilities/itinerary-deck-export.js +339 -0
  37. package/dist/capabilities/lavish-local.js +924 -0
  38. package/dist/capabilities/model-override.js +38 -0
  39. package/dist/capabilities/opensky.js +54 -43
  40. package/dist/capabilities/resilience.js +191 -0
  41. package/dist/capabilities/session/action-cache.js +126 -0
  42. package/dist/capabilities/session/adapters/ctrip-flight.js +141 -0
  43. package/dist/capabilities/session/adapters/ctrip-hotel.js +222 -0
  44. package/dist/capabilities/session/adapters/dida-portal.js +274 -0
  45. package/dist/capabilities/session/adapters/meituan-local.js +54 -0
  46. package/dist/capabilities/session/adapters/rail-12306.js +342 -0
  47. package/dist/capabilities/session/benchmark.js +189 -0
  48. package/dist/capabilities/session/bridge-ledger.js +224 -0
  49. package/dist/capabilities/session/extension-bridge.js +636 -0
  50. package/dist/capabilities/session/extension-channel.js +109 -0
  51. package/dist/capabilities/session/extension-distribution.js +230 -0
  52. package/dist/capabilities/session/extract.js +50 -0
  53. package/dist/capabilities/session/golden-score.js +114 -0
  54. package/dist/capabilities/session/health-watch.js +215 -0
  55. package/dist/capabilities/session/read-guard.js +107 -0
  56. package/dist/capabilities/session/static-flight-golden.js +150 -0
  57. package/dist/capabilities/session/transport.js +109 -0
  58. package/dist/capabilities/session/wizard.js +117 -0
  59. package/dist/capabilities/session-consent.js +178 -0
  60. package/dist/capabilities/session-login.js +170 -0
  61. package/dist/capabilities/session-search.js +686 -0
  62. package/dist/capabilities/spawn-bounded.js +203 -0
  63. package/dist/capabilities/sponsor-plugin.js +341 -0
  64. package/dist/capabilities/visa-policy.js +140 -0
  65. package/dist/capabilities/weather.js +243 -138
  66. package/dist/data/session-golden-20.json +25 -0
  67. package/dist/data/sf-golden-manifest.json +103 -0
  68. package/dist/data/sf-static-routes.json +91 -0
  69. package/dist/scripts/action-cache-tests.js +72 -0
  70. package/dist/scripts/agent-planning-turn-deadline-e2e.js +242 -0
  71. package/dist/scripts/agent-planning-turn-deadline-tests.js +185 -0
  72. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  73. package/dist/scripts/agent-reach-error-tests.js +354 -0
  74. package/dist/scripts/agent-reach-tests.js +26 -18
  75. package/dist/scripts/agent-reach-wrapper-tests.js +50 -50
  76. package/dist/scripts/anything-tests.js +116 -60
  77. package/dist/scripts/artifact-client-contract-tests.js +220 -0
  78. package/dist/scripts/artifacts-capability-tests.js +727 -0
  79. package/dist/scripts/async-collect.js +68 -10
  80. package/dist/scripts/benchmark-environment-bridge-e2e.js +741 -0
  81. package/dist/scripts/benchmark-environment-bridge-tests.js +1452 -0
  82. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +126 -0
  83. package/dist/scripts/booking-copilot-availability-policy-tests.js +265 -0
  84. package/dist/scripts/booking-copilot-bin-proof-tests.js +66 -0
  85. package/dist/scripts/booking-copilot-cleanup-proof-helper.js +76 -0
  86. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +144 -0
  87. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +424 -0
  88. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +2839 -0
  89. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +309 -0
  90. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +238 -0
  91. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +123 -0
  92. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +156 -0
  93. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +70 -0
  94. package/dist/scripts/booking-copilot-intent-proof-tests.js +422 -0
  95. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +184 -0
  96. package/dist/scripts/booking-copilot-planner-live-probe.js +95 -0
  97. package/dist/scripts/booking-copilot-proof-runtime.js +42 -0
  98. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +210 -0
  99. package/dist/scripts/booking-copilot-runtime-proof-tests.js +2349 -0
  100. package/dist/scripts/booking-copilot-server-proof-tests.js +784 -0
  101. package/dist/scripts/booking-copilot-startup-proof-tests.js +94 -0
  102. package/dist/scripts/booking-executor-tests.js +80 -0
  103. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  104. package/dist/scripts/booking-saga-tests.js +125 -0
  105. package/dist/scripts/booking-surface-contract-proof-tests.js +212 -0
  106. package/dist/scripts/bootstrap-tests.js +1479 -0
  107. package/dist/scripts/bridge-events-tests.js +239 -0
  108. package/dist/scripts/bridge-ledger-tests.js +428 -0
  109. package/dist/scripts/build-changelog.js +238 -0
  110. package/dist/scripts/build-metrics-report.js +331 -0
  111. package/dist/scripts/changelog-tests.js +118 -0
  112. package/dist/scripts/channel-probe-tests.js +103 -0
  113. package/dist/scripts/channel-probe.js +169 -0
  114. package/dist/scripts/channel-registry-tests.js +208 -0
  115. package/dist/scripts/companion-tests.js +67 -0
  116. package/dist/scripts/dida-cookie-probe.js +87 -0
  117. package/dist/scripts/dida-runner-stop-tests.js +311 -0
  118. package/dist/scripts/dida-sw-probe.js +114 -0
  119. package/dist/scripts/diff-test.js +17 -12
  120. package/dist/scripts/doctor-tests.js +564 -0
  121. package/dist/scripts/dsh-artifact-e2e.js +279 -0
  122. package/dist/scripts/dsh-artifact-web-e2e.js +675 -0
  123. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  124. package/dist/scripts/dsh-runtime-closure-tests.js +182 -0
  125. package/dist/scripts/dsh-runtime-closure.js +162 -0
  126. package/dist/scripts/dsh-session-v3-migration-proof.js +109 -0
  127. package/dist/scripts/dsh-subprocess-local-proof.js +150 -0
  128. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  129. package/dist/scripts/effect-tests.js +539 -0
  130. package/dist/scripts/engine-run.js +1 -3
  131. package/dist/scripts/engine-tests.js +16 -10
  132. package/dist/scripts/evaluation-cadence-tests.js +174 -0
  133. package/dist/scripts/evaluation-contract-tests.js +268 -0
  134. package/dist/scripts/extension-distribution-cli.js +39 -0
  135. package/dist/scripts/extension-distribution-tests.js +443 -0
  136. package/dist/scripts/extension-tests.js +988 -0
  137. package/dist/scripts/external-event-tests.js +350 -0
  138. package/dist/scripts/fact-gate-tests.js +869 -0
  139. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  140. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  141. package/dist/scripts/flight-malformed-tests.js +159 -0
  142. package/dist/scripts/flyai-setup-tests.js +228 -0
  143. package/dist/scripts/flyai-setup-tool-tests.js +54 -0
  144. package/dist/scripts/flyai-tests.js +692 -0
  145. package/dist/scripts/flyai-tool-registration-contract-tests.js +91 -0
  146. package/dist/scripts/fx-contract-tests.js +201 -0
  147. package/dist/scripts/g5-guard-tests.js +131 -0
  148. package/dist/scripts/g5-guard.js +189 -0
  149. package/dist/scripts/gen-lunar-anchors.js +106 -0
  150. package/dist/scripts/geo-atlas-tests.js +283 -0
  151. package/dist/scripts/gotry-backend-kernel-tests.js +206 -0
  152. package/dist/scripts/gotry-try-demo-tests.js +79 -0
  153. package/dist/scripts/gotry-try-demo.js +49 -0
  154. package/dist/scripts/ground-transfer-tests.js +956 -0
  155. package/dist/scripts/hbcli-e2e-tests.js +159 -0
  156. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  157. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  158. package/dist/scripts/hbcli-tests.js +121 -69
  159. package/dist/scripts/health-watch-cli.js +73 -0
  160. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  161. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  162. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  163. package/dist/scripts/i18n-tests.js +41 -0
  164. package/dist/scripts/incident-tests.js +281 -87
  165. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  166. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  167. package/dist/scripts/issue-271-liveness-tests.js +569 -0
  168. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  169. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  170. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  171. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  172. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  173. package/dist/scripts/issue436-persisted-routing-e2e.js +247 -0
  174. package/dist/scripts/itinerary-artifact-tests.js +368 -0
  175. package/dist/scripts/itinerary-deck-artifact-tests.js +416 -0
  176. package/dist/scripts/itinerary-deck-export-tests.js +552 -0
  177. package/dist/scripts/itinerary-deck-tests.js +435 -0
  178. package/dist/scripts/itinerary-html-tests.js +426 -0
  179. package/dist/scripts/journey-tests.js +22 -27
  180. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  181. package/dist/scripts/kernel-manifest-gate.js +126 -0
  182. package/dist/scripts/kernel-manifest-tests.js +212 -0
  183. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  184. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  185. package/dist/scripts/kernel-manifest.js +369 -0
  186. package/dist/scripts/lavish-local-tests.js +1226 -0
  187. package/dist/scripts/lavish-product-tools-tests.js +537 -0
  188. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  189. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  190. package/dist/scripts/ledger-tests.js +480 -0
  191. package/dist/scripts/ledger-workflow-crash.js +32 -0
  192. package/dist/scripts/managed-dsh-cleanup-diagnostic-proof.js +184 -0
  193. package/dist/scripts/managed-dsh-close-failure-proof.js +172 -0
  194. package/dist/scripts/managed-dsh-run-port-proof.js +153 -0
  195. package/dist/scripts/managed-dsh-terminal-proof.js +252 -0
  196. package/dist/scripts/map-tools-vendor-package-proof.js +365 -0
  197. package/dist/scripts/memory-capture-tests.js +242 -61
  198. package/dist/scripts/memory-decay-tests.js +49 -0
  199. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  200. package/dist/scripts/memory-lifecycle.js +196 -0
  201. package/dist/scripts/memory-metrics.js +29 -31
  202. package/dist/scripts/memory-value-report.js +631 -0
  203. package/dist/scripts/memory-value-tests.js +328 -0
  204. package/dist/scripts/metrics-report-tests.js +151 -0
  205. package/dist/scripts/model-override-e2e.js +137 -0
  206. package/dist/scripts/nightly-evidence-tests.js +161 -0
  207. package/dist/scripts/nightly-evidence.js +286 -0
  208. package/dist/scripts/nudge-digest.js +43 -42
  209. package/dist/scripts/observability-tests.js +381 -0
  210. package/dist/scripts/onboarding-tests.js +161 -0
  211. package/dist/scripts/opensky-check.js +18 -13
  212. package/dist/scripts/opensky-tests.js +28 -28
  213. package/dist/scripts/persona-surface-guard-tests.js +49 -0
  214. package/dist/scripts/pnpm-dsh-closure-proof.js +17 -0
  215. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  216. package/dist/scripts/price-drift-tests.js +340 -0
  217. package/dist/scripts/price-drift-watch.js +447 -0
  218. package/dist/scripts/probe-poi-tests.js +53 -45
  219. package/dist/scripts/product-metrics.js +483 -0
  220. package/dist/scripts/publish-preverify.js +110 -0
  221. package/dist/scripts/realtime-pricing-tests.js +76 -0
  222. package/dist/scripts/recall-tests.js +513 -0
  223. package/dist/scripts/replay-async.js +20 -18
  224. package/dist/scripts/replay-real.js +18 -18
  225. package/dist/scripts/replay.js +52 -82
  226. package/dist/scripts/session-attach-diagnose.js +31 -0
  227. package/dist/scripts/session-attach-poc.js +62 -0
  228. package/dist/scripts/session-benchmark.js +180 -0
  229. package/dist/scripts/session-dida-live-e2e.js +199 -0
  230. package/dist/scripts/session-extract-tests.js +60 -0
  231. package/dist/scripts/session-link-tests.js +478 -0
  232. package/dist/scripts/session-login.js +29 -0
  233. package/dist/scripts/session-tests.js +862 -0
  234. package/dist/scripts/session-zones-tests.js +358 -0
  235. package/dist/scripts/sf-live-benchmark.js +367 -0
  236. package/dist/scripts/sf-live-challenge-stop-tests.js +453 -0
  237. package/dist/scripts/sf-live-cli-tests.js +92 -0
  238. package/dist/scripts/sf-live-options.js +46 -0
  239. package/dist/scripts/sf-manifest-tests.js +30 -0
  240. package/dist/scripts/sf-manifest.js +33 -0
  241. package/dist/scripts/sf-soft-score-tests.js +67 -0
  242. package/dist/scripts/sf-summary-tests.js +324 -0
  243. package/dist/scripts/sf-summary.js +525 -0
  244. package/dist/scripts/share-tests.js +432 -0
  245. package/dist/scripts/skeleton-check.js +16 -14
  246. package/dist/scripts/skeleton-integration-test.js +28 -11
  247. package/dist/scripts/skills-contract-tests.js +51 -46
  248. package/dist/scripts/smoke-session-gate-tests.js +21 -0
  249. package/dist/scripts/smoke.js +703 -219
  250. package/dist/scripts/spawn-bounded-abort-tests.js +241 -0
  251. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  252. package/dist/scripts/state-cli-tests.js +261 -0
  253. package/dist/scripts/state-cli.js +585 -0
  254. package/dist/scripts/state-repair-cli.js +66 -0
  255. package/dist/scripts/state-repair-tests.js +140 -0
  256. package/dist/scripts/static-golden-tests.js +165 -0
  257. package/dist/scripts/time-eval-tests.js +210 -192
  258. package/dist/scripts/travel-timeline-tests.js +59 -0
  259. package/dist/scripts/turn-handoff-collect-tests.js +169 -0
  260. package/dist/scripts/turn-handoff-collect.js +161 -0
  261. package/dist/scripts/turn-policy-tests.js +55 -0
  262. package/dist/scripts/typed-contract-canary.js +176 -0
  263. package/dist/scripts/tz-resolver-sanity.js +50 -0
  264. package/dist/scripts/tz-resolver-tests.js +584 -0
  265. package/dist/scripts/unified-tests.js +23 -19
  266. package/dist/scripts/visa-policy-tests.js +62 -0
  267. package/dist/scripts/weather-tests.js +276 -41
  268. package/dist/scripts/wish-channel-gate-tests.js +54 -0
  269. package/dist/scripts/write-gate-tests.js +522 -0
  270. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  271. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  272. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  273. package/dist/scripts/z3-race-tests.js +41 -0
  274. package/dist/src/artifact-gate.js +829 -0
  275. package/dist/src/backend/kernel.js +91 -0
  276. package/dist/src/backend/modules/booking-copilot.js +92 -0
  277. package/dist/src/backend/modules/booking-executor.js +124 -0
  278. package/dist/src/backend/modules/session-search.js +551 -0
  279. package/dist/src/benchmark-agent-conformance.js +578 -0
  280. package/dist/src/benchmark-environment-bridge.js +515 -0
  281. package/dist/src/benchmark-headless-child-diagnostics.js +166 -0
  282. package/dist/src/benchmark-tool-isolation.js +151 -0
  283. package/dist/src/bookable-facts.js +533 -0
  284. package/dist/src/booking-saga.js +97 -0
  285. package/dist/src/booking-surface/availability-policy.js +697 -0
  286. package/dist/src/booking-surface/booking-intent.js +43 -0
  287. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  288. package/dist/src/booking-surface/canonical-schema.js +116 -0
  289. package/dist/src/booking-surface/contracts.js +75 -0
  290. package/dist/src/booking-surface/dsh-planner.js +1854 -0
  291. package/dist/src/booking-surface/dsh-plugin.js +378 -0
  292. package/dist/src/booking-surface/dsh-worker.js +98 -0
  293. package/dist/src/booking-surface/error-codes.js +231 -0
  294. package/dist/src/booking-surface/index.js +9 -0
  295. package/dist/src/booking-surface/managed-dsh-cleanup-diagnostic.js +97 -0
  296. package/dist/src/booking-surface/managed-dsh-run-port.js +244 -0
  297. package/dist/src/booking-surface/profile.js +24 -0
  298. package/dist/src/booking-surface/recovery-chain.js +367 -0
  299. package/dist/src/booking-surface/runtime.js +1968 -0
  300. package/dist/src/booking-surface/server.js +674 -0
  301. package/dist/src/booking-surface/startup.js +168 -0
  302. package/dist/src/booking-surface/validation.js +261 -0
  303. package/dist/src/bridge.js +20 -13
  304. package/dist/src/companions.js +86 -0
  305. package/dist/src/contracts.js +21 -43
  306. package/dist/src/dsh-llm.js +97 -111
  307. package/dist/src/engine.js +276 -193
  308. package/dist/src/evaluation-cadence.js +97 -0
  309. package/dist/src/evaluation-contracts.js +283 -0
  310. package/dist/src/flight-pack-adapter.js +54 -0
  311. package/dist/src/flight-pack-contract.js +2 -0
  312. package/dist/src/flyai-setup-tool.js +70 -0
  313. package/dist/src/gotry-backend.js +125 -0
  314. package/dist/src/hotel-date-gate.js +118 -0
  315. package/dist/src/i18n.js +88 -0
  316. package/dist/src/index.js +2305 -731
  317. package/dist/src/itinerary-deck.js +139 -0
  318. package/dist/src/itinerary-doc-shared.js +661 -0
  319. package/dist/src/itinerary-html.js +131 -0
  320. package/dist/src/journey.js +155 -108
  321. package/dist/src/lavish-tools.js +400 -0
  322. package/dist/src/ledger-repair-apply.js +538 -0
  323. package/dist/src/ledger-repair-plan.js +463 -0
  324. package/dist/src/loop.js +358 -162
  325. package/dist/src/memory-capture.js +180 -32
  326. package/dist/src/memory-decay.js +47 -0
  327. package/dist/src/memory-lifecycle.js +1310 -0
  328. package/dist/src/memory-utility.js +27 -40
  329. package/dist/src/mock-llm.js +36 -61
  330. package/dist/src/model.js +81 -36
  331. package/dist/src/realtime-pricing.js +103 -0
  332. package/dist/src/recall/card.js +74 -0
  333. package/dist/src/recall/evaluator.js +140 -0
  334. package/dist/src/recall/integration.js +17 -0
  335. package/dist/src/recall/tick.js +166 -0
  336. package/dist/src/session-link/plan-it-action.js +48 -0
  337. package/dist/src/session-link/session-link.js +248 -0
  338. package/dist/src/session-zones.js +507 -0
  339. package/dist/src/share/adapters.js +51 -0
  340. package/dist/src/share/share-consent.js +76 -0
  341. package/dist/src/share/share-deck.js +58 -0
  342. package/dist/src/share/share-token.js +146 -0
  343. package/dist/src/slot-spec.js +84 -106
  344. package/dist/src/state-ledger.js +767 -0
  345. package/dist/src/state-repair.js +299 -0
  346. package/dist/src/subagent-job-id-guard.js +58 -0
  347. package/dist/src/time-anchor.js +231 -33
  348. package/dist/src/tool-packet.js +20 -8
  349. package/dist/src/travel-slots.js +58 -35
  350. package/dist/src/travel-timeline.js +59 -0
  351. package/dist/src/turn-deadline.js +287 -0
  352. package/dist/src/turn-policy.js +118 -0
  353. package/dist/src/tz-resolver.js +143 -0
  354. package/dist/src/unified.js +538 -360
  355. package/dist/src/wish-pool.js +42 -12
  356. package/dist/src/write-gate.js +495 -0
  357. package/dist/src/z3-shared.js +284 -0
  358. package/extension/README.md +21 -0
  359. package/extension/README.zh-CN.md +21 -0
  360. package/extension/background.js +463 -0
  361. package/extension/content-bridge.js +270 -0
  362. package/extension/content-main.js +111 -0
  363. package/extension/manifest.json +60 -0
  364. package/package.json +407 -14
  365. package/schemas/booking.intent.schema.json +193 -0
  366. package/schemas/booking.surface.schema.json +2622 -0
  367. package/ts/capabilities/agent-reach-deep.ts +114 -0
  368. package/ts/capabilities/agent-reach.ts +198 -0
  369. package/ts/capabilities/anything.ts +255 -0
  370. package/ts/capabilities/artifacts.ts +428 -0
  371. package/ts/capabilities/fact-log.ts +54 -0
  372. package/ts/capabilities/flyai-config.ts +388 -0
  373. package/ts/capabilities/flyai.ts +1274 -0
  374. package/ts/capabilities/hbcli.ts +253 -71
  375. package/ts/capabilities/incident-log.ts +88 -59
  376. package/ts/capabilities/itinerary-artifact.ts +214 -0
  377. package/ts/capabilities/itinerary-deck-artifact.ts +218 -0
  378. package/ts/capabilities/itinerary-deck-export.ts +378 -0
  379. package/ts/capabilities/opensky.ts +121 -0
  380. package/ts/capabilities/session/action-cache.ts +148 -0
  381. package/ts/capabilities/session/adapters/ctrip-flight.ts +181 -0
  382. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  383. package/ts/capabilities/session/adapters/dida-portal.ts +338 -0
  384. package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
  385. package/ts/capabilities/session/adapters/rail-12306.ts +392 -0
  386. package/ts/capabilities/session/benchmark.ts +276 -0
  387. package/ts/capabilities/session/bridge-ledger.ts +333 -0
  388. package/ts/capabilities/session/extension-bridge.ts +794 -0
  389. package/ts/capabilities/session/extension-channel.ts +144 -0
  390. package/ts/capabilities/session/extension-distribution.ts +266 -0
  391. package/ts/capabilities/session/extract.ts +54 -0
  392. package/ts/capabilities/session/golden-score.ts +139 -0
  393. package/ts/capabilities/session/health-watch.ts +264 -0
  394. package/ts/capabilities/session/read-guard.ts +141 -0
  395. package/ts/capabilities/session/static-flight-golden.ts +209 -0
  396. package/ts/capabilities/session/transport.ts +146 -0
  397. package/ts/capabilities/session/wizard.ts +140 -0
  398. package/ts/capabilities/session-consent.ts +211 -0
  399. package/ts/capabilities/session-login.ts +208 -0
  400. package/ts/capabilities/session-search.ts +893 -0
  401. package/ts/capabilities/weather.ts +330 -0
  402. package/ts/dsh-runtime/vendor/README.md +68 -0
  403. package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
  404. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +405 -0
  405. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +404 -0
  406. package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +1584 -0
  407. package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
  408. package/ts/dsh-runtime/vendor/dsh-map-tools/icon.svg +5 -0
  409. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +595 -0
  410. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
  411. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +58 -0
  412. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
  413. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
  414. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +131 -0
  415. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
  416. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/geo.js +232 -0
  417. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +120 -0
  418. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +40 -0
  419. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/staticmap-route.js +115 -0
  420. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
  421. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
  422. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +267 -0
  423. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +90 -0
  424. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
  425. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
  426. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
  427. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
  428. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +23 -0
  429. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
  430. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/geo.d.ts +61 -0
  431. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
  432. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
  433. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/staticmap-route.d.ts +31 -0
  434. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
  435. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
  436. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +75 -0
  437. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +68 -0
  438. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
  439. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +97 -0
  440. package/ts/package.json +296 -3
  441. package/ts/scripts/async-collect.ts +91 -0
  442. package/ts/scripts/map-tools-vendor-package-proof.ts +404 -0
  443. package/ts/scripts/skeleton-check.ts +1 -1
  444. package/ts/scripts/skeleton-integration-test.ts +23 -1
  445. package/ts/scripts/state-cli.ts +568 -0
  446. package/ts/scripts/turn-handoff-collect.ts +178 -0
  447. package/ts/src/artifact-gate.ts +991 -0
  448. package/ts/src/benchmark-agent-conformance.ts +619 -0
  449. package/ts/src/benchmark-environment-bridge.ts +538 -0
  450. package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
  451. package/ts/src/benchmark-tool-isolation.ts +166 -0
  452. package/ts/src/bookable-facts.ts +837 -0
  453. package/ts/src/booking-saga.ts +133 -0
  454. package/ts/src/booking-surface/availability-policy.ts +522 -0
  455. package/ts/src/booking-surface/booking-intent.ts +86 -0
  456. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  457. package/ts/src/booking-surface/canonical-schema.js +116 -0
  458. package/ts/src/booking-surface/contracts.ts +475 -0
  459. package/ts/src/booking-surface/dsh-planner.ts +1949 -0
  460. package/ts/src/booking-surface/dsh-plugin.js +378 -0
  461. package/ts/src/booking-surface/dsh-worker.ts +110 -0
  462. package/ts/src/booking-surface/error-codes.ts +241 -0
  463. package/ts/src/booking-surface/index.ts +9 -0
  464. package/ts/src/booking-surface/managed-dsh-cleanup-diagnostic.ts +151 -0
  465. package/ts/src/booking-surface/managed-dsh-run-port.ts +273 -0
  466. package/ts/src/booking-surface/profile.ts +42 -0
  467. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  468. package/ts/src/booking-surface/runtime.ts +1793 -0
  469. package/ts/src/booking-surface/server.ts +733 -0
  470. package/ts/src/booking-surface/startup.ts +201 -0
  471. package/ts/src/booking-surface/validation.ts +217 -0
  472. package/ts/src/companions.ts +113 -0
  473. package/ts/src/contracts.ts +13 -3
  474. package/ts/src/dsh-llm.ts +44 -11
  475. package/ts/src/flyai-setup-tool.ts +77 -0
  476. package/ts/src/index.ts +2107 -224
  477. package/ts/src/itinerary-deck.ts +189 -0
  478. package/ts/src/itinerary-doc-shared.ts +741 -0
  479. package/ts/src/itinerary-html.ts +171 -0
  480. package/ts/src/loop.ts +278 -40
  481. package/ts/src/memory-capture.ts +283 -0
  482. package/ts/src/memory-decay.ts +55 -0
  483. package/ts/src/memory-utility.ts +70 -0
  484. package/ts/src/mock-llm.ts +4 -8
  485. package/ts/src/model.ts +88 -2
  486. package/ts/src/recall/card.ts +106 -0
  487. package/ts/src/recall/evaluator.ts +170 -0
  488. package/ts/src/recall/integration.ts +35 -0
  489. package/ts/src/recall/tick.ts +213 -0
  490. package/ts/src/session-link/plan-it-action.ts +78 -0
  491. package/ts/src/session-link/session-link.ts +275 -0
  492. package/ts/src/share/adapters.ts +93 -0
  493. package/ts/src/share/share-consent.ts +105 -0
  494. package/ts/src/share/share-deck.ts +85 -0
  495. package/ts/src/share/share-token.ts +166 -0
  496. package/ts/src/slot-spec.ts +172 -0
  497. package/ts/src/state-ledger.ts +912 -0
  498. package/ts/src/subagent-job-id-guard.ts +72 -0
  499. package/ts/src/time-anchor.ts +338 -0
  500. package/ts/src/tool-packet.ts +32 -0
  501. package/ts/src/travel-slots.ts +219 -0
  502. package/ts/src/travel-timeline.ts +83 -0
  503. package/ts/src/turn-deadline.ts +366 -0
  504. package/ts/src/turn-policy.ts +154 -0
  505. package/ts/src/unified.ts +360 -89
  506. package/ts/src/wish-pool.ts +79 -0
@@ -0,0 +1,862 @@
1
+ /**
2
+ * 会话数据面 P1 测试(RFC §4 P1 exit):
3
+ * A-E 纯函数(确定性,进 CI):ReadGuard 分类器/批搜解析(buildEntryUrl/fixture)/提交件过滤/节律闸;
4
+ * F live FlyAI 官方通道(天气回归为 deterministic fixture;live 天气观测不进 merge gate);
5
+ * G live 会话检索(Chrome + 携程;**默认 SKIP**——测试永不自动开用户浏览器窗口,GOTRY_SESSION_LIVE=1 显式开启);
6
+ * H 酒店通道(flyai search-hotel;纯 CLI,无浏览器窗口);
7
+ * I 账号会话授权闸(纯函数:每会话一次/拒绝=会话内吊销/allow/off/无审批通道,确定性);
8
+ * J 登录引导(无凭证语义/表格完备/票据名级检查;live opt-in 同 G)。
9
+ * K 酒店会话面(2026-09-03 实装:entry URL/走形解析/形状嗅探/城市码指引/闸面;纯函数,transport 前短路)。
10
+ * L 火车会话面(2026-09-03 实装:12306 管道行解析/行级签名防索引漂移/电报码指引/闸面;公开查询面)。
11
+ * #21 字段 scorer/双源 gate 的无网络验收独立放在 scripts/session-benchmark.ts。
12
+ * 隔离纪律:live 用 mktemp profile 与 stateRoot,绝不动共享状态与日常浏览器 profile;
13
+ * 任何测试不自动弹浏览器窗口(用户 Chrome 只经 CDP attach 或用户手动运行 session-login)。
14
+ */
15
+ import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { classifyRequest, isSubmitText } from '../capabilities/session/read-guard.js';
19
+ import { buildEntryUrl, parseBatchSearch, parseBatchSearchResult } from '../capabilities/session/adapters/ctrip-flight.js';
20
+ import { buildHotelEntryUrl, parseCtripHotelList, looksLikeHotelListBody } from '../capabilities/session/adapters/ctrip-hotel.js';
21
+ import { buildTrainEntryUrl, hasRecognizedAvailableSeat, parseLeftTicketQuery, parseLeftTicketQueryResult, resolveTrainQueryTelecodes, STATION_TELECODES, validateTrainQueryResponseUrl } from '../capabilities/session/adapters/rail-12306.js';
22
+ import { buildDidaEntryUrl, parseDidaRates, parseDidaSearchCache, looksLikeDidaRatesBody } from '../capabilities/session/adapters/dida-portal.js';
23
+ import { sessionFlightSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch, trainStationUnresolvedHint, hotelCityUnresolvedHint, didaLoginHint, __resetRateLimiterForTest, __setTrainSessionTransportForTest, classifyTransportFailure } from '../capabilities/session-search.js';
24
+ import { flyaiSearch } from '../capabilities/flyai.js';
25
+ import { createConsentGate } from '../capabilities/session-consent.js';
26
+ import { sessionLogin, pollTicketNames, LOGIN_TARGETS } from '../capabilities/session-login.js';
27
+ import { factsFromHotel, factsFromSessionTrain, TRAIN_FACT_FRESHNESS_RULE, TRAIN_FACT_MAX_AGE_MS } from '../src/bookable-facts.js';
28
+ let pass = 0;
29
+ let fail = 0;
30
+ function assert(cond, label, detail) {
31
+ if (cond) {
32
+ pass += 1;
33
+ console.log(` ok - ${label}`);
34
+ }
35
+ else {
36
+ fail += 1;
37
+ console.log(` FAIL - ${label}${detail !== undefined ? ' :: ' + JSON.stringify(detail)?.slice(0, 300) : ''}`);
38
+ }
39
+ }
40
+ // A. ReadGuard 网络层分类器:方法 × URL 双因子(搜索 POST 不误伤,写请求必拦)
41
+ console.log('A. classifyRequest(方法×URL 双因子)');
42
+ assert(classifyRequest('POST', 'https://flights.ctrip.com/international/search/api/search/batchSearch?v=1') === 'allow', '搜索 POST(batchSearch)放行');
43
+ assert(classifyRequest('POST', 'https://m.ctrip.com/restapi/soa2/12345/SubmitOrder') === 'block', 'POST /SubmitOrder 拦截(驼峰复合)');
44
+ assert(classifyRequest('POST', 'https://m.ctrip.com/restapi/soa2/12345/createOrder') === 'block', 'POST /createOrder 拦截(驼峰复合)');
45
+ assert(classifyRequest('GET', 'https://pay.ctrip.com/pay/payment?x=1') === 'block', 'GET 硬支付模式拦截(任意方法)');
46
+ assert(classifyRequest('GET', 'https://m.ctrip.com/restapi/soa2/15095/SearchBoxRecommend') === 'allow', 'GET 搜索建议放行');
47
+ assert(classifyRequest('PUT', 'https://api.example.com/trade/create') === 'block', 'PUT /trade/create 拦截');
48
+ assert(classifyRequest('OPTIONS', 'https://flights.ctrip.com/api/preflight') === 'allow', 'OPTIONS 放行');
49
+ // B. batchSearch 解析(实测 schema fixture,2026-08-28)
50
+ console.log('B. parseBatchSearch(fixture)');
51
+ const fixture = JSON.stringify({
52
+ status: 0,
53
+ data: {
54
+ flightItineraryList: [
55
+ {
56
+ flightSegments: [{ airlineName: '吉祥航空', duration: 200, flightList: [{ flightNo: 'HO5577', departureAirportName: '虹桥国际机场', arrivalAirportName: '三义机场', departureDateTime: '2026-10-01 07:35:00', arrivalDateTime: '2026-10-01 10:55:00', aircraftName: '波音737(中)' }] }],
57
+ priceList: [{ adultPrice: 3240 }, { adultPrice: 2980 }],
58
+ },
59
+ { flightSegments: [{}] }, // malformed 项:跳过不抛
60
+ ],
61
+ },
62
+ });
63
+ const parsed = parseBatchSearch(fixture);
64
+ assert(parsed.length === 1, 'malformed 项跳过,1 个有效行程');
65
+ assert(parsed[0]?.flightNo === 'HO5577' && parsed[0]?.price === 2980, 'flightNo + priceList 最小 adultPrice');
66
+ assert(parsed[0]?.depDateTime === '2026-10-01 07:35:00' && parsed[0]?.durationMin === 200, '时刻与时长字段');
67
+ assert(parseBatchSearch('not-json').length === 0, '非 JSON 返空不抛错');
68
+ // B2. issue #279 结构化解析(malformed 不抛 + 形状异常归 error,合法空归 miss,非空归 hit)
69
+ console.log('B2. parseBatchSearchResult(issue #279 形状异常归 error,合法空归 miss,命中归 hit)');
70
+ {
71
+ const fixtureHit = JSON.stringify({ data: { flightItineraryList: [{
72
+ flightSegments: [{ airlineName: '吉祥航空', duration: 200, flightList: [{ flightNo: 'HO5577', departureAirportName: '虹桥', arrivalAirportName: '三义', departureDateTime: '2026-10-01 07:35:00', arrivalDateTime: '2026-10-01 10:55:00' }] }],
73
+ priceList: [{ adultPrice: 3240 }, { adultPrice: 2980 }],
74
+ }] } });
75
+ const rHit = parseBatchSearchResult(fixtureHit);
76
+ assert(rHit.verdict === 'hit' && rHit.options.length === 1 && rHit.options[0]?.flightNo === 'HO5577' && rHit.options[0]?.price === 2980, '命中 fixture → hit + 1 option + 最低价', rHit);
77
+ assert(rHit.shape === 'itinerary_rows_usable', '命中只记录固定形状枚举,不记录回包值');
78
+ const rMiss = parseBatchSearchResult(JSON.stringify({ data: { flightItineraryList: [] } }));
79
+ assert(rMiss.verdict === 'miss' && rMiss.options.length === 0, '已识别合法空(数组存在且空) → miss(不为 hit 也不为 error)', rMiss);
80
+ assert(rMiss.shape === 'itinerary_list_empty', '合法空的结构形状固定');
81
+ assert(parseBatchSearchResult('').shape === 'invalid_json', '非 JSON 形状固定');
82
+ assert(parseBatchSearchResult('null').shape === 'root_not_object', '非对象根形状固定');
83
+ assert(parseBatchSearchResult('{}').shape === 'data_not_object', '缺 data 形状固定');
84
+ assert(parseBatchSearchResult('{"data":{}}').shape === 'itinerary_list_not_array', '缺列表形状固定');
85
+ // 不抛错 + 形状未识别 → error(关键:不可静默收敛为 miss,把未知响应误判为「这条线路没航班」)
86
+ const malformed = [
87
+ ['JSON null body', 'null'],
88
+ ['非对象根(数字)', '123'],
89
+ ['非对象根(数组)', '[1,2,3]'],
90
+ ['空对象', '{}'],
91
+ ['缺 data 字段', JSON.stringify({ foo: 1 })],
92
+ ['data 非对象(null)', JSON.stringify({ data: null })],
93
+ ['data 是数组', JSON.stringify({ data: [] })],
94
+ ['flightItineraryList 非数组(对象)', JSON.stringify({ data: { flightItineraryList: { foo: 1 } } })],
95
+ ['flightItineraryList 非数组(字符串)', JSON.stringify({ data: { flightItineraryList: 'abc' } })],
96
+ ['flightItineraryList 非数组(数字)', JSON.stringify({ data: { flightItineraryList: 42 } })],
97
+ ['非 JSON 文本', 'not-json{'],
98
+ ['空字符串', ''],
99
+ ];
100
+ for (const [label, body] of malformed) {
101
+ let verdict = 'error';
102
+ let optionsLen = -1;
103
+ let threw = false;
104
+ try {
105
+ const r = parseBatchSearchResult(body);
106
+ verdict = r.verdict;
107
+ optionsLen = r.options.length;
108
+ }
109
+ catch {
110
+ threw = true;
111
+ }
112
+ assert(!threw && verdict === 'error' && optionsLen === 0, `${label} → verdict=error(不抛,不为 miss)`, { verdict, optionsLen, threw });
113
+ }
114
+ // priceList 非数组:item 行字段虽合法,但整行结构不可信 → error,不暴露 options
115
+ const priceMalformed = JSON.stringify({ data: { flightItineraryList: [{
116
+ flightSegments: [{ flightList: [{ flightNo: 'HO1', departureDateTime: '2026-10-01 07:35:00' }] }],
117
+ priceList: 'not-an-array',
118
+ }] } });
119
+ const rPm = parseBatchSearchResult(priceMalformed);
120
+ assert(rPm.verdict === 'error' && rPm.options.length === 0, 'priceList 显式非数组 → error 且不暴露 options', rPm);
121
+ // 已识别列表非空 + 行全部畸形 → error(没有可呈现的航段,不伪装成 miss)
122
+ const allBadRows = JSON.stringify({ data: { flightItineraryList: [
123
+ { flightSegments: [{}] },
124
+ 'not-an-object-row',
125
+ null,
126
+ ] } });
127
+ const rAb = parseBatchSearchResult(allBadRows);
128
+ assert(rAb.verdict === 'error' && rAb.options.length === 0, '非空列表行全畸形 → error(不伪装成 miss)', rAb);
129
+ assert(rAb.shape === 'itinerary_rows_unusable', '畸形行形状固定');
130
+ // 混合列表保留有效项,忽略畸形兄弟行
131
+ const mixed = JSON.stringify({ data: { flightItineraryList: [
132
+ { flightSegments: [{}], priceList: [{ adultPrice: 'bad' }] },
133
+ { flightSegments: [{ flightList: [{ flightNo: 'HO2', departureDateTime: '2026-10-01 08:00:00' }] }], priceList: [] },
134
+ ] } });
135
+ const rMixed = parseBatchSearchResult(mixed);
136
+ assert(rMixed.verdict === 'hit' && rMixed.options.length === 1 && rMixed.options[0]?.flightNo === 'HO2' && rMixed.options[0]?.price === 0, '混合列表 → 保留有效项并忽略畸形兄弟行', rMixed);
137
+ // 兼容性:旧 API parseBatchSearch 与新 API options 字段逐项一致
138
+ const compatParsed = parseBatchSearch(fixtureHit);
139
+ assert(compatParsed.length === rHit.options.length && compatParsed[0]?.flightNo === rHit.options[0]?.flightNo && compatParsed[0]?.price === rHit.options[0]?.price, '旧 API parseBatchSearch 返回的 options 字段与新 API 一致(兼容性)', { compat: compatParsed[0], new: rHit.options[0] });
140
+ }
141
+ // C. adapter entry URL
142
+ console.log('C. buildEntryUrl(城市码表)');
143
+ const e1 = buildEntryUrl('上海', '丽江', '2026-10-01');
144
+ assert(e1.ok === true && e1.url === 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-10-01', '上海→丽江 URL 精确');
145
+ const e2 = buildEntryUrl('乌兰巴托', '丽江', '2026-10-01');
146
+ assert(e2.ok === false && e2.unresolved?.[0] === '乌兰巴托', '词表外城市 unresolved 逐字保留(不猜)');
147
+ assert(buildEntryUrl('上海', '丽江', '十月一号').ok === false, '非法日期拒绝');
148
+ // D. DOM 提交件过滤
149
+ console.log('D. isSubmitText(DOM 提交件黑名单)');
150
+ assert(isSubmitText('立即下单') && isSubmitText('去支付') && isSubmitText('提交订单'), '下单/支付/提交 命中');
151
+ assert(!isSubmitText('搜索') && !isSubmitText('筛选'), '搜索/筛选 不误伤');
152
+ // E. 节律闸(无需 Chrome:闸在 entry 构建前触发)
153
+ console.log('E. 节律闸(≥30s 间隔)');
154
+ __resetRateLimiterForTest();
155
+ const r1 = await sessionFlightSearch({ from: '乌兰巴托', to: '丽江', date: '2026-10-01' });
156
+ const r2 = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2026-10-01' });
157
+ assert(r1.verdict === 'error' && /unresolved/.test(r1.error ?? ''), '首次调用:词表外 → error(unresolved)');
158
+ assert(r2.verdict === 'cooldown', '30s 内二次调用 → cooldown,不发起导航');
159
+ __resetRateLimiterForTest();
160
+ // F. transport 失败分类 + live FlyAI 官方通道
161
+ console.log('F. transport verdict + flyaiSearch(live,飞猪官方,无 key;GOTRY_SESSION_LIVE=0 跳过)');
162
+ assert(classifyTransportFailure('日常 Chrome 未开调试端口', true) === 'needs-attach', '调试端口未开稳定投影 needs-attach');
163
+ assert(classifyTransportFailure('cdp attach 失败:socket closed', true) === 'needs-attach', 'CDP 握手失败稳定投影 needs-attach');
164
+ assert(classifyTransportFailure('chrome launch failed', false) === 'error', '隔离 profile 启动失败不误投影用户门禁');
165
+ let fr = null;
166
+ if (process.env.GOTRY_SESSION_LIVE === '0') {
167
+ console.log(' SKIP - GOTRY_SESSION_LIVE=0(离线门禁不调用 FlyAI 外部实时端点)');
168
+ }
169
+ else {
170
+ fr = await flyaiSearch({ kind: 'flight', origin: '上海', destination: '丽江', depDate: '2026-10-01' });
171
+ const sentinelBlocked = fr.verdict === 'error' && /sentinel|block|trial limit/i.test(fr.error ?? '');
172
+ if (sentinelBlocked) {
173
+ console.log(' WARN - 飞猪上游限流(Sentinel 2026-08-28 / trial-limit 429 2026-08-31 实测,配额未文档化)——降级合同验证通过,跳过 hit 断言');
174
+ assert(fr.ok === false && /\[实时API:flyai@error@/.test(fr.evidence), '限流降级:结构化 error + 证据链错误形');
175
+ }
176
+ else {
177
+ assert(fr.ok === true && fr.verdict === 'hit', '上海→丽江 hit', fr);
178
+ assert((fr.options?.length ?? 0) >= 1 && (fr.options?.every((o) => o.price > 0 && /^\d+[A-Z]\d+|^[A-Z]{2}\d+/.test(o.no)) ?? false), '结构化字段齐(price>0,航班号形)', fr.options?.[0]);
179
+ }
180
+ assert(/\[实时API:flyai/.test(fr.evidence), '证据链 [实时API:flyai@*]');
181
+ }
182
+ // F2. 离线回归(issue #24):上游语义失败——CLI exit=0 且 {"data":null,"message":"出发日期非法"}
183
+ // 必须带上游原话走结构化 error,不得吞成 miss(miss 会误导模型「这条线路没有航班」)
184
+ {
185
+ const fakeDir = mkdtempSync(join(tmpdir(), 'flyai-fake-'));
186
+ const fakeCli = join(fakeDir, 'flyai-cli-fake');
187
+ writeFileSync(fakeCli, '#!/bin/sh\necho \'{"data":null,"message":"出发日期非法","status":1,"systemMessage":null}\'\nexit 0\n', { mode: 0o755 });
188
+ const fr2 = await flyaiSearch({ kind: 'flight', origin: '深圳', destination: '普吉', depDate: '2026-07-18', cliBin: fakeCli });
189
+ assert(fr2.ok === false && fr2.verdict === 'error', 'data:null 语义失败 → error 终态(非 miss)', fr2);
190
+ assert(/出发日期非法/.test(fr2.error ?? '') && /flyai@error@/.test(fr2.evidence), '上游原话透传 + 证据链错误形', fr2);
191
+ rmSync(fakeDir, { recursive: true, force: true });
192
+ }
193
+ // F3. stdout 截断回归(issue #84):CLI 分片异步写 >64KB 后立即 exit——
194
+ // 管道消费丢未 flush 尾部(实测截 ~7.6KB,静默 exit=0);文件重定向同步写不丢。
195
+ {
196
+ const bigDir = mkdtempSync(join(tmpdir(), 'flyai-trunc-'));
197
+ const payloadJs = join(bigDir, 'payload.js');
198
+ const itemCount = 100;
199
+ writeFileSync(payloadJs, `
200
+ const items = []
201
+ for (let i = 0; i < ${itemCount}; i++) {
202
+ items.push({
203
+ journeys: [{ segments: [{
204
+ marketingTransportName: '吉祥航空', marketingTransportNo: 'HO' + (1000 + i),
205
+ depDateTime: '2026-09-15 08:30', arrDateTime: '2026-09-15 12:10',
206
+ depStationName: '浦东国际机场', arrStationName: '长水机场', duration: '220', seatClassName: '经济舱',
207
+ }] }],
208
+ ticketPrice: String(800 + i),
209
+ jumpUrl: 'https://fliggy.com/item/' + 'x'.repeat(800) + i,
210
+ })
211
+ }
212
+ const payload = JSON.stringify({ data: { itemList: items } })
213
+ const step = Math.ceil(payload.length / 100)
214
+ for (let i = 0; i < 100; i++) process.stdout.write(payload.slice(i * step, (i + 1) * step))
215
+ process.exit(0)
216
+ `);
217
+ const bigBin = join(bigDir, 'flyai-cli-big');
218
+ writeFileSync(bigBin, `#!/bin/sh\nexec node "${payloadJs}"\n`, { mode: 0o755 });
219
+ const tr = await flyaiSearch({ kind: 'flight', origin: '上海', destination: '昆明', depDate: '2026-09-15', cliBin: bigBin });
220
+ assert(tr.ok === true && tr.verdict === 'hit' && tr.options?.length === itemCount, `大载荷 ${itemCount} 条(>64KB)完整解析无截断(#84)`, { verdict: tr.verdict, options: tr.options?.length, error: tr.error });
221
+ rmSync(bigDir, { recursive: true, force: true });
222
+ }
223
+ // G. live 会话检索(默认 SKIP:例行动回归**永不**自启浏览器窗口—— founder 2026-08-29 反馈
224
+ // 「匿名窗口反复打开携程/浏览器闪退」= 测试骚扰,live 须显式 GOTRY_SESSION_LIVE=1 请求)
225
+ console.log('G. sessionFlightSearch(live,Chrome+携程;默认跳过,GOTRY_SESSION_LIVE=1 显式开启)');
226
+ if (process.env.GOTRY_SESSION_LIVE !== '1') {
227
+ console.log(' SKIP - 会话 live 探针默认关(测试不再自动开浏览器窗口;GOTRY_SESSION_LIVE=1 显式开启)');
228
+ }
229
+ else {
230
+ __resetRateLimiterForTest();
231
+ const iso = mkdtempSync(join(tmpdir(), 'gotry-session-test-'));
232
+ // G1 登录闸合同:匿名实例默认拒绝(headless 本地启 Chrome,不触网)
233
+ const nl = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2026-10-01', profileDir: join(iso, 'anon-profile'), headless: true });
234
+ assert(nl.verdict === 'needs-login' && nl.ok === false, '匿名实例 → needs-login(不导航不发请求)', nl);
235
+ __resetRateLimiterForTest();
236
+ // G2 链路自检:allowAnonymous 显式开闸后,嗅探/解析/守卫全链验证(真实登录态走 scripts/session-login.ts 后的默认 profile)
237
+ const sr = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2026-10-01', profileDir: join(iso, 'profile'), headless: false, allowAnonymous: true, auditPath: join(iso, 'audit', 'session-incidents.jsonl') });
238
+ if (sr.verdict === 'challenged') {
239
+ console.log(` WARN - 风控命中(平台方差,合同路径验证通过):${sr.error}`);
240
+ assert(sr.ok === false && sr.verdict === 'challenged', 'challenged = degraded 语义正确(不重试不绕过)');
241
+ }
242
+ else if (sr.verdict === 'error' && /chrome launch failed/.test(sr.error ?? '')) {
243
+ console.log(' SKIP - 本机无 Chrome(channel:chrome 不可用)');
244
+ }
245
+ else if (sr.verdict === 'error' && /chrome launch failed/i.test(sr.error ?? '')) {
246
+ console.log(' SKIP - 本机无 Chrome(channel:chrome 不可用)');
247
+ }
248
+ else if ((sr.verdict === 'miss' || sr.verdict === 'error') && /blocked=0/.test(sr.evidence)) {
249
+ // 匿名自检态 0 options = 外部会话数据面未取回(站点方差/匿名态;三值语义与 Sentinel 限流同构:
250
+ // miss ≠ 链路断,守卫/证据/审计合同仍在 miss 证据里验证)。真 hit 收敛挂 founder 登录态(D-13)。
251
+ console.log(` SKIP - 会话数据面 miss(外部方差,合同降级语义):${sr.evidence.slice(0, 90)}`);
252
+ }
253
+ else if (sr.verdict === 'error' && /timeout/i.test(sr.error ?? '')) {
254
+ // 页面 30s 未完成加载(外部网络方差;与 flyai 端点不可达降级同构:可达性不进合并闸)
255
+ console.log(` SKIP - 携程页面加载超时(外部方差,合同降级语义):${String(sr.error).slice(0, 80)}`);
256
+ assert(sr.ok === false && /\[会话:ctrip-flight@error@/.test(sr.evidence), '超时降级仍带证据链错误形');
257
+ }
258
+ else {
259
+ assert(sr.ok === true && sr.verdict === 'hit' && (sr.options?.length ?? 0) >= 1, '上海→丽江 会话嗅探 hit', sr);
260
+ assert(sr.options?.every((o) => o.price > 0 && o.depDateTime.includes('2026-10-01')) ?? false, '班期=查询日,价格>0', sr.options?.[0]);
261
+ assert(/\[会话:ctrip-flight@/.test(sr.evidence) && /blocked=0/.test(sr.evidence), '证据链 [会话:*] + ReadGuard 零拦截(纯只读)');
262
+ assert(!existsSync(join(iso, 'audit', 'session-incidents.jsonl')), '审计文件不出现(零写请求)');
263
+ // 双源对照(记录式):FlyAI vs 携程会话 同查询最低价
264
+ const flyaiMin = Math.min(...(fr?.options?.map((o) => o.price).filter((p) => p > 0) ?? [0]));
265
+ const sessionMin = Math.min(...(sr.options?.map((o) => o.price).filter((p) => p > 0) ?? [0]));
266
+ console.log(` 双源对照:FlyAI 最低 ¥${flyaiMin} vs 会话(携程)最低 ¥${sessionMin}(同日同线路,记录不判等)`);
267
+ }
268
+ rmSync(iso, { recursive: true, force: true });
269
+ }
270
+ // H. 酒店通道(2026-08-29 平铺接入):live 双合法终态 + 离线解析 + 参数闸
271
+ console.log('H. flyaiSearch hotel(飞猪官方 search-hotel)');
272
+ {
273
+ // H1 live:带日期两形态(成对可选;未定档期可不带日期)
274
+ if (process.env.GOTRY_SESSION_LIVE === '0') {
275
+ console.log(' SKIP - GOTRY_SESSION_LIVE=0(离线门禁不调用 FlyAI 酒店实时端点)');
276
+ }
277
+ else {
278
+ const hr = await flyaiSearch({ kind: 'hotel', destName: '大理', checkInDate: '2026-10-01', checkOutDate: '2026-10-03' });
279
+ const hSentinel = hr.verdict === 'error' && /sentinel|block/i.test(hr.error ?? '');
280
+ if (hSentinel) {
281
+ console.log(' WARN - 飞猪 Sentinel 限流——降级合同验证通过,跳过 hit 断言');
282
+ assert(hr.ok === false && /\[实时API:flyai@error@/.test(hr.evidence), '酒店限流降级:结构化 error + 证据链错误形');
283
+ }
284
+ else if (hr.ok === false) {
285
+ console.log(` WARN - flyai hotel 端点降级(${String(hr.error).slice(0, 60)})——证据链合同通过,hit 断言跳过`);
286
+ assert(/\[实时API:flyai@error@/.test(hr.evidence), '端点降级仍带证据链错误形');
287
+ }
288
+ else {
289
+ assert(hr.verdict === 'hit' && (hr.hotels?.length ?? 0) >= 1, '大理酒店 hit', hr.hotels?.[0]);
290
+ assert(hr.hotels?.every(h => !!h.name && !!h.jumpUrl) ?? false, '条目结构化(name + jumpUrl 透传)', hr.hotels?.[0]);
291
+ }
292
+ assert(/\[实时API:flyai/.test(hr.evidence), '证据链 [实时API:flyai@*]');
293
+ }
294
+ // H2 离线 malformed-batch 合同:有效酒店 + malformed sibling → 整体 error,zero facts
295
+ const fakeDir = mkdtempSync(join(tmpdir(), 'flyai-hotel-fake-'));
296
+ const fakeCliH = join(fakeDir, 'flyai-hotel-fake');
297
+ writeFileSync(fakeCliH, '#!/bin/sh\necho \'{"data":{"itemList":[{"name":"大理A 酒店","shId":"1","star":"高档型","rate":null,"price":"\\u00a57xx","address":"addr","interestsPoi":"近洱海","detailUrl":"https://router.feizhu.com/x"},{"star":"舒适型"}]}}\'\nexit 0\n', { mode: 0o755 });
298
+ const h2 = await flyaiSearch({ kind: 'hotel', destName: '大理', cliBin: fakeCliH });
299
+ assert(h2.verdict === 'error' && h2.hotels === undefined, '酒店有效+malformed sibling → 整体 structured error', h2);
300
+ assert(/hotel itemList malformed.*1\/2/i.test(h2.evidence), '酒店 mixed error 暴露 malformed/总条目比例', h2);
301
+ const h2Facts = factsFromHotel({ source: 'flyai-hotel', destination: '大理', checkIn: '2026-10-01', checkOut: '2026-10-03', verdict: h2.verdict, options: 0, evidence: h2.evidence, fetchedAt: new Date().toISOString() });
302
+ assert(h2Facts.length === 0, '酒店 mixed error 不落酒店事实', h2Facts);
303
+ // H3 单一完整合法酒店 → hit/1,字段保真(priceRaw/star/jumpUrl)
304
+ writeFileSync(fakeCliH, '#!/bin/sh\necho \'{"data":{"itemList":[{"name":"大理A 酒店","shId":"1","star":"高档型","rate":null,"price":"\\u00a57xx","address":"addr","interestsPoi":"近洱海","detailUrl":"https://router.feizhu.com/x"}]}}\'\nexit 0\n', { mode: 0o755 });
305
+ const h3 = await flyaiSearch({ kind: 'hotel', destName: '大理', cliBin: fakeCliH });
306
+ assert(h3.verdict === 'hit' && h3.hotels?.length === 1, '单一完整酒店 → hit/1', h3);
307
+ const h0 = h3.hotels?.[0];
308
+ assert(h0?.name === '大理A 酒店' && h0?.priceRaw === '¥7xx' && h0?.price === 0, '打码价保 priceRaw 原值(数字价 0)', h0);
309
+ assert(h0?.star === '高档型' && h0?.hotelId === '1' && h0?.jumpUrl === 'https://router.feizhu.com/x', 'star/jumpUrl(shId/detailUrl)透传', h0);
310
+ // H4 参数闸:无目的地 / 日期不成对 / 非规整日期 都走结构化 error,不发上游
311
+ const hb1 = await flyaiSearch({ kind: 'hotel' });
312
+ assert(hb1.verdict === 'error' && /destName|目的地/.test(hb1.error ?? ''), '缺目的地 → bad args error', hb1);
313
+ const hb2 = await flyaiSearch({ kind: 'hotel', destName: '大理', checkInDate: '2026-10-01' });
314
+ assert(hb2.verdict === 'error' && /成对/.test(hb2.error ?? ''), '入住/退房不成对 → error(不静默)', hb2);
315
+ assert((await flyaiSearch({ kind: 'hotel', destName: '大理', checkInDate: '10月1号', checkOutDate: '2026-10-03' })).verdict === 'error', '非法日期格式 → error');
316
+ rmSync(fakeDir, { recursive: true, force: true });
317
+ }
318
+ // I. 账号会话授权闸(纯函数;v2 语义:每会话每站点一次,拒绝=会话内吊销,不再逐次弹卡)
319
+ console.log('I. createConsentGate(账号会话授权:每会话一次/拒绝吊销/allow/off/无通道)');
320
+ {
321
+ const next = async () => ({ kind: 'allow' });
322
+ const agentA = { id: 'agent-A' };
323
+ const agentB = { id: 'agent-B' };
324
+ const sess = (a = agentA) => ({ name: 'gotry_session_search', agent: a, callId: 'c1', arguments: { kind: 'flight' } });
325
+ const other = () => ({ name: 'gotry_anything_search', agent: agentA });
326
+ const mkStore = () => new WeakMap();
327
+ const mkGate = (access, seam) => createConsentGate({ access: () => access, approval: seam ? () => seam : undefined, store: mkStore() });
328
+ // I1 无审批通道(headless/极简宿主):账号工具 → ask(交运行时 fail-closed);非账号工具放行
329
+ const gateBare = createConsentGate({ access: () => 'ask' });
330
+ const d1 = await gateBare({ name: 'gotry_session_search', agent: undefined, arguments: { from: '上海' } }, next);
331
+ assert(d1.kind === 'ask' && /携程机票/.test(String(d1.reason ?? '')), '缺省 kind → flight 默认并交运行时 fail-closed ask', d1);
332
+ assert((await gateBare({ name: 'gotry_anything_search', agent: undefined }, next)).kind === 'allow', '非账号工具不过闸,原样放行');
333
+ // I2 批准一次 → 会话内记住:第二次免弹卡直接放行(审批请求计数恒 1)
334
+ {
335
+ let requests = 0;
336
+ const seam = { request: async () => { requests += 1; return 'allowed-once'; } };
337
+ const gate = createConsentGate({ access: () => 'ask', approval: () => seam });
338
+ const r1 = await gate(sess(), next);
339
+ assert(r1.kind === 'allow' && requests === 1, '首次调用:弹卡一次,批准后放行', { r1, requests });
340
+ const r2 = await gate(sess(), next);
341
+ assert(r2.kind === 'allow' && requests === 1, '会话内第二次调用免弹卡直接放行(不重复骚扰)', { r2: r1, requests });
342
+ }
343
+ // I3 拒绝 = 本会话吊销:deny 且不再弹卡;另一会话不受影响
344
+ {
345
+ let requests = 0;
346
+ const seam = { request: async () => { requests += 1; return 'rejected'; } };
347
+ const store = mkStore();
348
+ const gate = createConsentGate({ access: () => 'ask', approval: () => seam, store });
349
+ const d1 = await gate(sess(), next);
350
+ assert(d1.kind === 'deny' && /拒绝/.test(String(d1.kind === 'deny' ? d1.reason : '')), '拒绝 → deny + 明示「本会话内生效」', d1);
351
+ const d2 = await gate(sess(), next);
352
+ assert(d2.kind === 'deny' && requests === 1, '拒绝后再次调用 → 直接 deny,不再弹卡(拒绝=吊销)', { d2 });
353
+ const dB = await gate({ name: 'gotry_session_search', agent: agentB, arguments: { kind: 'flight' } }, next);
354
+ assert(dB.kind === 'deny' && requests === 2, '另一会话不受此前拒绝影响——会重新发起一次审批请求(seam 本例仍拒)', { dB, requests });
355
+ }
356
+ // I4 off 总闸:不弹卡直接 deny;非账号工具不受影响
357
+ {
358
+ const gate = mkGate('off');
359
+ const d = await gate(sess(), next);
360
+ assert(d.kind === 'deny' && /sessionAccess=off/.test(String(d.kind === 'deny' ? d.reason : '')), 'off → 不弹卡直接 deny', d);
361
+ const train = await gate({ name: 'gotry_session_search', agent: agentA, arguments: { kind: 'train' } }, next);
362
+ assert(train.kind === 'deny' && /sessionAccess=off/.test(String(train.kind === 'deny' ? train.reason : '')), 'off → train 公开查询面同样 deny(不绕过总闸)', train);
363
+ assert((await gate(other(), next)).kind === 'allow', 'off 只关账号面工具,其余放行');
364
+ }
365
+ // I5 allow(配置级预授权):直接放行,不弹卡
366
+ {
367
+ const gate = mkGate('allow');
368
+ assert((await gate(sess(), next)).kind === 'allow', 'sessionAccess=allow → 配置明示预授权,直接放行');
369
+ assert((await gate({ name: 'gotry_session_search', agent: agentA, arguments: { kind: 'train' } }, next)).kind === 'allow', 'sessionAccess=allow → train 也按总闸预授权放行');
370
+ }
371
+ // I5b train 的 ask/拒绝隔离:公开查询面仍必须走同一站点授权桶。
372
+ {
373
+ let requests = 0;
374
+ const seam = { request: async () => { requests += 1; return requests === 1 ? 'rejected' : 'allowed-once'; } };
375
+ const trainAgent = { id: 'agent-train' };
376
+ const gate = createConsentGate({ access: () => 'ask', approval: () => seam });
377
+ const rejected = await gate({ name: 'gotry_session_search', agent: trainAgent, arguments: { kind: 'flight' } }, next);
378
+ assert(rejected.kind === 'deny' && requests === 1, 'ask 首次 flight 拒绝 → deny + 请求数恰为 1', rejected);
379
+ const allowed = await gate({ name: 'gotry_session_search', agent: trainAgent, arguments: { kind: 'train' } }, next);
380
+ assert(allowed.kind === 'allow' && requests === 2, '拒绝 flight 不污染 train:另一站点恰再请求一次并允许', allowed);
381
+ const cached = await gate({ name: 'gotry_session_search', agent: trainAgent, arguments: { kind: 'train' } }, next);
382
+ assert(cached.kind === 'allow' && requests === 2, 'train 同站批准缓存:后续调用不再请求', cached);
383
+ }
384
+ // I6 site-bound(#308):授权与拒绝均按 site(kind)分桶,跨站不得互授;同站复用放行;
385
+ // unknown/malformed kind 失败关闭(不扩权);query-first 冲突形态与 flat args 一致。
386
+ {
387
+ let requests = 0;
388
+ const seam = { request: async () => { requests += 1; return 'allowed-once'; } };
389
+ const store = mkStore();
390
+ const gate = createConsentGate({ access: () => 'ask', approval: () => seam, store });
391
+ // 6a flat args:首次 dida 弹卡 + 放行;同会话再调 ctrip-flight 必须再弹卡(跨站不互授)
392
+ const r1 = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-dida', arguments: { kind: 'dida' } }, next);
393
+ assert(r1.kind === 'allow' && requests === 1, 'flat args:首次 dida 弹卡 + 放行', { r1, requests });
394
+ const r2 = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-ctrip', arguments: { kind: 'flight' } }, next);
395
+ assert(r2.kind === 'allow' && requests === 2, 'flat args:ctrip-flight 跨站必须再弹卡', { r2, requests });
396
+ // 6b 同站(flight)二次调用:免弹卡直接放行
397
+ const r3 = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-ctrip-2', arguments: { kind: 'flight' } }, next);
398
+ assert(r3.kind === 'allow' && requests === 2, 'flat args:ctrip-flight 同站复用,免弹卡', { r3, requests });
399
+ // 6c wrapped args:{ query: { kind: 'hotel' } } 形态必须同样识别为 ctrip-hotel
400
+ const r4 = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-hotel', arguments: { query: { kind: 'hotel' }, kind: 'flight' } }, next);
401
+ assert(r4.kind === 'allow' && requests === 3, 'kind=hotel → ctrip-hotel 站点弹卡(独立分桶)', { r4, requests });
402
+ // 6d 拒绝隔离:拒绝 ctrip-flight 后,同会话调 dida 仍须弹卡(deny 不跨站污染)
403
+ let rejReq = 0;
404
+ const seamRej = { request: async () => { rejReq += 1; return 'rejected'; } };
405
+ const gateRej = createConsentGate({ access: () => 'ask', approval: () => seamRej, store });
406
+ const agentRej = { id: 'agent-rej' };
407
+ const d1 = await gateRej({ name: 'gotry_session_search', agent: agentRej, callId: 'r1', arguments: { kind: 'flight' } }, next);
408
+ assert(d1.kind === 'deny' && rejReq === 1, '拒绝 ctrip-flight → deny(弹卡一次)', { d1, rejReq });
409
+ const d2 = await gateRej({ name: 'gotry_session_search', agent: agentRej, callId: 'r2', arguments: { kind: 'dida' } }, next);
410
+ assert(d2.kind === 'deny' && rejReq === 2, '拒绝不跨站污染:同会话 dida 仍须弹卡(被拒后)', { d2, rejReq });
411
+ // 6e unknown kind 失败关闭(不弹卡,不让过;无审批通道语义但带 agent 时更稳)
412
+ const rU = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-xx', arguments: { kind: 'not-a-kind' } }, next);
413
+ assert(rU.kind === 'deny', 'unknown kind → fail-closed deny(不扩权)', rU);
414
+ const rMalformed = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-malformed', arguments: { kind: 42 } }, next);
415
+ assert(rMalformed.kind === 'deny', 'malformed kind → fail-closed deny(不把异常降成 flight)', rMalformed);
416
+ }
417
+ }
418
+ // J. 登录引导(产品工具 gotry_session_login 的能力层;纯函数/确定性部分)
419
+ // 语义红线(founder):登录永远发生在外部网站——gotry 永不经手密码/验证码/cookie 值,
420
+ // 只读「票据 cookie 名」这个存在性事实(名称级,0 个值过手)。
421
+ console.log('J. sessionLogin(登录引导:无凭证语义/表格完备/pending 语义)');
422
+ {
423
+ // J1 表格完备:每个站点必须有 domain/names/label/entryUrl,票据名表非空
424
+ for (const [site, t] of Object.entries(LOGIN_TARGETS)) {
425
+ assert(!!t.domain && t.names.length > 0 && !!t.label && t.entryUrl.startsWith('https://'), `${site} 登录目标表完备`, t);
426
+ }
427
+ // J2 未知站点 → 结构化 error,不抛错
428
+ const unknownSite = await sessionLogin({ site: 'not-a-site', waitMs: 0 });
429
+ assert(unknownSite.ok === false && unknownSite.verdict === 'error' && /未知站点/.test(unknownSite.error ?? ''), '未知站点 → 结构化 error(降级不抛)', unknownSite);
430
+ // J3 pollTicketNames 名称级:只回名字,绝不携带任何 cookie 值(值即便在 fixture 里也到不了 results)
431
+ {
432
+ const fakeBrowser = { cookies: async () => [
433
+ { domain: '.ctrip.com', name: 'cticket', value: 'SECRET-TICKET' },
434
+ { domain: 'ctrip.com', name: 'uid', value: 'SECRET-UID' },
435
+ { domain: 'ctrip.com', name: 'irrelevant', value: 'x' },
436
+ ] };
437
+ const names = await pollTicketNames(fakeBrowser, LOGIN_TARGETS['ctrip-flight']);
438
+ assert(JSON.stringify(names) === '["cticket","uid"]', '票据名级检查:只回名字;值(SOCRET)永不进入结果', names);
439
+ const joined = JSON.stringify(names);
440
+ assert(!joined.includes('SECRET'), '存在性检查零值过手(fixture 值不泄露)', joined);
441
+ }
442
+ // J4 live(opt-in,同 G 节纪律):不交互的短等待 → needs-attach(Chrome 未开调试)或 pending(入口已开)
443
+ if (process.env.GOTRY_SESSION_LIVE === '1') {
444
+ const lr = await sessionLogin({ waitMs: 2500, pollMs: 500 });
445
+ if (lr.verdict === 'needs-attach') {
446
+ assert(lr.ok === false && /chrome:\/\/inspect/.test(lr.error ?? ''), 'live:Chrome 未开调试 → needs-attach + 一次性指引', lr);
447
+ }
448
+ else if (lr.verdict === 'logged-in') {
449
+ // 自动检测快路径:票据已在 → 零弹窗直接确认(不打开任何页面)
450
+ assert(lr.ok === true && (lr.tickets?.length ?? 0) > 0, 'live:已登录自动检测 → logged-in(零弹窗,票据名级)', lr);
451
+ }
452
+ else {
453
+ assert(lr.ok === true && lr.verdict === 'pending', 'live:attach 成功不交互 → pending(入口已开,等人登录)', lr);
454
+ const evidenceTagRe = /\[会话:ctrip-flight-login@/;
455
+ if (!evidenceTagRe.test(lr.evidence ?? ''))
456
+ throw new Error(`FAIL: 登录证据链缺失,实际 ${lr.evidence}`);
457
+ pass += 1;
458
+ console.log(' ok - 登录证据链 [会话:ctrip-flight-login@*] 形态');
459
+ }
460
+ }
461
+ else {
462
+ console.log(' SKIP - 登录 live 探针默认关(GOTRY_SESSION_LIVE=1 opt-in;工具面/桌面入口才真调)');
463
+ }
464
+ }
465
+ // K. 酒店会话面(2026-09-03 实装,迪拜 session 复盘:会话面此前只有机票)
466
+ console.log('K. 酒店适配器(buildHotelEntryUrl/走形解析/形状嗅探/城市码指引/节律与日期闸)');
467
+ {
468
+ // K1 entry URL:cityId 覆盖 / 码表 / 未收录
469
+ const h1 = buildHotelEntryUrl({ to: '迪拜', cityId: 220, checkIn: '2026-12-01', checkOut: '2026-12-03', adults: 2 });
470
+ assert(h1.ok && h1.url === 'https://hotels.ctrip.com/hotels/list?city=220&checkin=2026-12-01&checkout=2026-12-03&adult=2', 'cityId 显式覆盖 + 参数序', h1);
471
+ const h2 = buildHotelEntryUrl({ to: '上海' });
472
+ assert(h2.ok && h2.url === 'https://hotels.ctrip.com/hotels/list?city=2', '码表内城市(上海=2,实测校准)', h2);
473
+ const h3 = buildHotelEntryUrl({ to: '不在码表的城市' });
474
+ assert(!h3.ok && h3.unresolved?.includes('不在码表的城市') === true, '未收录城市 unresolved(不猜 id,不造数)', h3);
475
+ assert(/city=/.test(hotelCityUnresolvedHint(['迪拜'])), '未收录指引带 cityId 发现路径(web 搜 list 页 URL)');
476
+ // K1b 实测校准抽查:迪拜=220/丽江=37/大理=36(2026-09-03 页面 title 验证)
477
+ const hDubai = buildHotelEntryUrl({ to: '迪拜' });
478
+ assert(hDubai.ok && hDubai.url.includes('city=220'), '迪拜=220(实测,轨迹里 agent 构造的 id 正确)', hDubai);
479
+ const hLijiang = buildHotelEntryUrl({ to: '丽江' });
480
+ assert(hLijiang.ok && hLijiang.url.includes('city=37'), '丽江=37(实测)', hLijiang);
481
+ // K2 走形解析:domestic 形态(priceInfo 对象)/扁平形态/打码价/malformed
482
+ // K2b 携程现行真实形态(一方校准 2026-09-03:hotelInfo 包裹 + roomInfo[].priceInfo.price 官方价格路径)
483
+ const realShape = JSON.stringify({
484
+ initListData: { hotelList: [{
485
+ hotelInfo: {
486
+ summary: { hotelId: '3732301', nameInfo: { name: 'Ibis Styles Dubai Jumeira', names: ['Ibis Styles Dubai Jumeira'] }, hotelStar: { star: 3 }, positionInfo: { address: 'Al Mina Road - Jumeirah 1' } },
487
+ commentInfo: { commentScore: '4.4' },
488
+ roomInfo: [
489
+ { priceInfo: { price: 520 } },
490
+ { priceInfo: { price: 468, displayPrice: '¥468起' } },
491
+ ],
492
+ },
493
+ }] },
494
+ });
495
+ const realHotels = parseCtripHotelList(realShape);
496
+ assert(realHotels.length === 1, '真实形态(hotelInfo 包裹)解析命中', realHotels);
497
+ assert(realHotels[0].name === 'Ibis Styles Dubai Jumeira' && realHotels[0].hotelId === '3732301' && realHotels[0].star === 3 && realHotels[0].score === '4.4', '结构化字段(名/id/星/评分)', realHotels[0]);
498
+ assert(realHotels[0].price === 468, '多房型取最低价(roomInfo[].priceInfo.price 官方路径)', realHotels[0]);
499
+ assert(realHotels[0].jumpUrl === 'https://hotels.ctrip.com/hotel/3732301', '真实形态 jumpUrl 构造', realHotels[0]);
500
+ // K2c 加密价形态(priceToken 在而无 price):不伪造任何价,条目保留(名/星/评分仍可用)
501
+ const encryptedShape = JSON.stringify({
502
+ hotelList: [{
503
+ hotelInfo: {
504
+ summary: { hotelId: '999', nameInfo: { names: ['Atlantis The Palm'] }, hotelStar: { star: 5 } },
505
+ commentInfo: { commentScore: '4.8' },
506
+ roomInfo: [{ priceInfo: { priceToken: 'ENCRYPTED_TOKEN', isHiddenPrice: false } }],
507
+ },
508
+ }],
509
+ });
510
+ const encHotels = parseCtripHotelList(encryptedShape);
511
+ assert(encHotels.length === 1, '加密价条目不丢弃(名/星/评分仍可呈现)', encHotels);
512
+ assert(encHotels[0].price === 0 && !encHotels[0].priceRaw, '加密价不伪造价格(价以 jumpUrl 落地页为准)', encHotels[0]);
513
+ // K2d displayPrice 字符串兜底(无数值 price 时)
514
+ const displayShape = JSON.stringify({ hotelList: [{ hotelInfo: { summary: { hotelId: '888', nameInfo: { names: ['Rove Downtown'] } }, roomInfo: [{ priceInfo: { displayPrice: '¥7xx' } }] } }] });
515
+ const dispHotels = parseCtripHotelList(displayShape);
516
+ assert(dispHotels.length === 1 && dispHotels[0].priceRaw === '¥7xx' && dispHotels[0].price === 0, 'displayPrice 字符串价原样保留(打码口径)', dispHotels[0]);
517
+ const fixture = JSON.stringify({
518
+ data: { hotelList: [
519
+ { hotelId: 442516, hotelName: 'Dubai Marriott', star: 5, commentScore: 4.7, position: { address: 'Sheikh Zayed Rd' }, priceInfo: { avgPrice: 680, total: 1360 } },
520
+ { hotelId: '999', hotelName: 'Rove Downtown', priceInfo: { priceDisplay: '¥7xx' } },
521
+ ] },
522
+ });
523
+ const hotels1 = parseCtripHotelList(fixture);
524
+ assert(hotels1.length === 2, '走形解析命中 hotelList 数组', hotels1);
525
+ assert(hotels1[0].name === 'Dubai Marriott' && hotels1[0].price === 680 && hotels1[0].star === 5 && hotels1[0].score === '4.7', '归一化字段(名/价/星/评分)', hotels1[0]);
526
+ assert(hotels1[0].jumpUrl === 'https://hotels.ctrip.com/hotel/442516', 'jumpUrl 由 hotelId 构造(预订由人完成)', hotels1[0]);
527
+ assert(hotels1[1].price === 0 && hotels1[1].priceRaw === '¥7xx', '打码价原样保留不数值化(不伪装真价)', hotels1[1]);
528
+ const hotels2 = parseCtripHotelList(JSON.stringify({ hotelMatchInfos: [{ name: 'Atlantis', price: 2100, score: '4.8', hotelId: '123' }] }));
529
+ assert(hotels2.length === 1 && hotels2[0].price === 2100, '扁平形态(顶层 price 数字)同样命中', hotels2);
530
+ assert(parseCtripHotelList('not json{').length === 0 && parseCtripHotelList('{"a":1}').length === 0, 'malformed/无签名 一律返空(不抛错)');
531
+ // K3 形状嗅探签名
532
+ assert(looksLikeHotelListBody('{"data":{"hotelList":[]}}') === true, '签名命中 hotelList');
533
+ assert(looksLikeHotelListBody('{"data":{"userList":[]}}') === false, '无签名不转发(防无关响应误投)');
534
+ assert(looksLikeHotelListBody('x'.repeat(2_000_001)) === false, '超大响应体不嗅探(上限把关)');
535
+ // K4 闸面:日期对闸(不发上游)/城市未收录 error/节律闸 cooldown(全在 transport 之前,零桥零浏览器)
536
+ const dPast = await sessionHotelSearch({ to: '上海', checkIn: '2026-01-01', checkOut: '2026-01-03' });
537
+ assert(dPast.ok === false && dPast.verdict === 'error' && /不是未来合法区间/.test(dPast.error ?? ''), '过去入住日代码层拒绝,不发上游', dPast);
538
+ const dPair = await sessionHotelSearch({ to: '上海', checkIn: '2026-12-01' });
539
+ assert(dPair.ok === false && /成对/.test(dPair.error ?? ''), 'checkIn/checkOut 须成对', dPair);
540
+ __resetRateLimiterForTest();
541
+ const e1 = await sessionHotelSearch({ to: '不在码表的城市' });
542
+ assert(e1.ok === false && e1.verdict === 'error' && /city=/.test(e1.error ?? ''), '未收录城市 → error + cityId 指引', e1);
543
+ const e2 = await sessionHotelSearch({ to: '上海' });
544
+ assert(e2.ok === false && e2.verdict === 'cooldown', '节律闸:同站点 30s 内第二调 → cooldown(不发起导航)', e2);
545
+ }
546
+ // L. 火车会话面(2026-09-03 实装,12306 公开查询面)纯函数:entry URL/管道行解析/电报码指引/闸面
547
+ console.log('L. 火车适配器(buildTrainEntryUrl/parseLeftTicketQuery/电报码指引/节律闸)');
548
+ {
549
+ // L1 entry URL:码表 / 显式电报码 / 未收录
550
+ const t1 = buildTrainEntryUrl({ from: '上海', to: '昆明', date: '2026-12-01' });
551
+ assert(t1.ok && t1.url.includes('fs=%E4%B8%8A%E6%B5%B7%2CSHH') && t1.url.includes('ts=%E6%98%86%E6%98%8E%2CKMM') && t1.url.includes('date=2026-12-01'), '城市电报码(fs=城市名,SHH 形态)', t1);
552
+ const t2 = buildTrainEntryUrl({ from: '丽江', to: '大理', date: '2026-12-01', fromStationTelecode: 'EHM', toStationTelecode: 'KDM' });
553
+ assert(t2.ok && t2.url.includes('%2CEHM') && t2.url.includes('%2CKDM'), '显式电报码覆盖(码表外城市)', t2);
554
+ const t3 = buildTrainEntryUrl({ from: '敦煌', to: '西双版纳', date: '2026-12-01' });
555
+ assert(!t3.ok && t3.unresolved?.includes('敦煌') === true, '码表外城市 unresolved(官方站表亦无城市组,不猜码)', t3);
556
+ assert(/fromStationTelecode/.test(trainStationUnresolvedHint(['敦煌'])), '未收录指引带电报码发现路径(web 搜 kyfw 查询页 URL)');
557
+ // 电报码表防漂移:逐条锁定官方站表快照(data/stations-12306-verify.json,
558
+ // 2026-09-03 自 kyfw station_name.js 校准;曾纠出南宁 NIZ→NNZ)
559
+ {
560
+ const snap = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', 'data', 'stations-12306-verify.json'), 'utf-8'));
561
+ const names = Object.keys(snap.cities);
562
+ assert(names.length >= 129, `快照覆盖 ${names.length} 城`);
563
+ let drift = 0;
564
+ for (const [city, v] of Object.entries(snap.cities)) {
565
+ if (STATION_TELECODES[city] !== v.telecode) {
566
+ drift += 1;
567
+ console.log(` FAIL-detail - 电报码漂移: ${city} 表=${STATION_TELECODES[city]} 快照=${v.telecode}`);
568
+ }
569
+ }
570
+ assert(drift === 0, `电报码表 129 城与官方快照零漂移(漂移 ${drift} 处)`);
571
+ assert(Object.keys(STATION_TELECODES).length === names.length, '表与快照城市集合一致(无表外多城)');
572
+ }
573
+ const t4 = buildTrainEntryUrl({ from: '南宁', to: '丽江', date: '2026-12-01' });
574
+ assert(t4.ok && t4.url.includes('%2CNNZ'), '南宁=NNZ(官方站表校准,曾错 NIZ)且丽江已入表=LHM', t4);
575
+ const exactResponseUrl = 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM';
576
+ const resolvedQuery = resolveTrainQueryTelecodes({ from: '丽江', to: '大理', date: '2026-12-01', fromStationTelecode: 'EHM', toStationTelecode: 'KDM' });
577
+ assert(resolvedQuery.ok && resolvedQuery.telecodes.fromStationTelecode === 'EHM' && resolvedQuery.telecodes.toStationTelecode === 'KDM', '请求电报码解析保留显式覆盖');
578
+ const exactBinding = validateTrainQueryResponseUrl(exactResponseUrl, { fromStationTelecode: 'SHH', toStationTelecode: 'KMM', date: '2026-12-01' });
579
+ assert(exactBinding.ok && exactBinding.binding.url === exactResponseUrl, 'response URL exact host/path/telecodes/date → bound');
580
+ for (const [label, url] of [
581
+ ['wrong host', exactResponseUrl.replace('kyfw.12306.cn', 'evil.example')],
582
+ ['wrong path', exactResponseUrl.replace('/otn/leftTicket/queryG', '/otn/leftTicket/init')],
583
+ ['missing date', exactResponseUrl.replace('leftTicketDTO.train_date=2026-12-01&', '')],
584
+ ['wrong route', exactResponseUrl.replace('from_station=SHH', 'from_station=BJP')],
585
+ ]) {
586
+ assert(!validateTrainQueryResponseUrl(url, { fromStationTelecode: 'SHH', toStationTelecode: 'KMM', date: '2026-12-01' }).ok, `${label} response URL → fail closed`);
587
+ }
588
+ // L2 管道行解析(官方 cN 口径:站名走 data.map,座位桶 20-33 第一方校准;
589
+ // 行按官方索引程序化构造,杜绝手数偏移)
590
+ const makeRow = (seats, canWebBuy = 'Y', startTrainDate = '20261201') => {
591
+ const c = new Array(56).fill('');
592
+ c[2] = '24000000G1375';
593
+ c[3] = 'G1375';
594
+ c[4] = 'SHH';
595
+ c[5] = 'KMM';
596
+ c[6] = 'SHH';
597
+ c[7] = 'KMM';
598
+ c[8] = '07:35';
599
+ c[9] = '15:27';
600
+ c[10] = '07:52';
601
+ c[11] = canWebBuy;
602
+ c[12] = 'yp';
603
+ c[13] = startTrainDate;
604
+ c[14] = 'x';
605
+ c[15] = 'loc';
606
+ c[16] = '01';
607
+ c[17] = '02';
608
+ c[18] = 'Y';
609
+ c[19] = '0';
610
+ for (const [k, v] of Object.entries(seats))
611
+ c[Number(k)] = v;
612
+ return c.join('|');
613
+ };
614
+ const entry = buildTrainEntryUrl({ from: '上海', to: '昆明', date: '2026-12-01' });
615
+ assert(entry.ok, 'entry 构造成功');
616
+ // 官方 cN 索引探针:第 i 位座位桶写入其下标字串,解析结果必须 label→下标逐一相等
617
+ const probeSeats = {};
618
+ for (let i = 20; i <= 33; i++)
619
+ probeSeats[i] = String(i);
620
+ const probeRow = makeRow(probeSeats);
621
+ const probe = parseLeftTicketQuery(JSON.stringify({ data: { result: [probeRow], map: { SHH: '上海南', KMM: '昆明' } } }), entry.url ?? '');
622
+ assert(probe.length === 1, '探针行解析', probe);
623
+ const expectSeats = [['其他(通勤)', '20'], ['高级软卧', '21'], ['其他', '22'], ['软卧(一等卧)', '23'], ['软座', '24'], ['特等座', '25'], ['无座', '26'], ['yp_b(上游席别)', '27'], ['硬卧(二等卧)', '28'], ['硬座', '29'], ['二等座', '30'], ['一等座', '31'], ['商务座', '32'], ['动卧', '33']];
624
+ for (const [label, idxVal] of expectSeats) {
625
+ assert(probe[0].seats[label] === idxVal, `座位桶 ${label} 下标=${idxVal}(官方 cN)`, probe[0].seats);
626
+ }
627
+ assert(probe[0].fromStation === '上海南' && probe[0].toStation === '昆明', '站名= data.map[电报码](官方 cN 口径,非行内索引)', probe[0]);
628
+ assert(parseLeftTicketQueryResult(JSON.stringify({ data: { result: [probeRow], map: { SHH: '上海南', KMM: '昆明' } } }), entry.url ?? '').kind === 'recognized-nonempty', 'typed parser:有效非空批次 → recognized-nonempty');
629
+ const trains1 = parseLeftTicketQuery(JSON.stringify({ data: { result: [makeRow({ 25: '有', 26: '有', 28: '有', 29: '有', 30: '有', 31: '有', 32: '有' })], map: { SHH: '上海南', KMM: '昆明' } } }), entry.url ?? '');
630
+ assert(trains1.length === 1, '管道行解析命中', trains1);
631
+ assert(trains1[0].trainCode === 'G1375' && trains1[0].depTime === '07:35' && trains1[0].arrTime === '15:27' && trains1[0].durationMin === 472, '车次/时刻/历时归一化', trains1[0]);
632
+ assert(trains1[0].seats['商务座'] === '有' && trains1[0].seats['二等座'] === '有' && trains1[0].seats['特等座'] === '有' && trains1[0].seats['硬卧(二等卧)'] === '有', '余票分桶(官方索引)', trains1[0].seats);
633
+ assert(!('price' in trains1[0]), '列表接口无票价——不伪装价格(诚实面)');
634
+ const shifted = makeRow({ 25: '有' }).split('|').slice(1).join('|');
635
+ assert(parseLeftTicketQuery(JSON.stringify({ data: { result: [shifted], map: {} } }), entry.url ?? '').length === 0, '索引漂移行(签名失配)整行跳过,fail-visible');
636
+ assert(parseLeftTicketQuery('not json', entry.url ?? '').length === 0 && parseLeftTicketQuery('{"data":{"result":[]}}', entry.url ?? '').length === 0, '兼容数组投影:malformed/空 一律返空(不抛错)');
637
+ const malformedLimitedFields = [
638
+ ['invalid departure minute', makeRow({ 30: '有' }).replace('07:35', '07:99'), { SHH: '上海', KMM: '昆明' }],
639
+ ['invalid arrival minute', makeRow({ 30: '有' }).replace('15:27', '15:99'), { SHH: '上海', KMM: '昆明' }],
640
+ ['invalid calendar start train date', makeRow({ 30: '有' }, 'Y', '20261399'), { SHH: '上海', KMM: '昆明' }],
641
+ ['non-string station map value', makeRow({ 30: '有' }), { SHH: 123, KMM: '昆明' }],
642
+ ];
643
+ for (const [label, row, map] of malformedLimitedFields) {
644
+ const outcome = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [row], map } }), entry.url ?? '');
645
+ assert(outcome.kind === 'malformed', `${label} → malformed typed outcome`, outcome);
646
+ }
647
+ // L2b. #355 typed outcome + train fact counterexamples. The converter
648
+ // consumes invocation binding only; no caller timestamp/query metadata.
649
+ const requested = { from: '上海', to: '昆明', date: '2026-12-01' };
650
+ const now = new Date('2026-09-10T12:00:00.000Z');
651
+ const binding = {
652
+ requested,
653
+ request: { fromStationTelecode: 'SHH', toStationTelecode: 'KMM', date: requested.date },
654
+ response: {
655
+ url: 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM',
656
+ fromStationTelecode: 'SHH', toStationTelecode: 'KMM', date: requested.date,
657
+ },
658
+ batchId: 'fixture-hit-batch', queryId: 'session:12306-train:fixture-hit-batch', fetchedAt: '2026-09-10T11:59:00.000Z',
659
+ };
660
+ for (const [label, row, map] of malformedLimitedFields) {
661
+ const outcome = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [row], map } }), entry.url ?? '');
662
+ assert(outcome.kind === 'malformed' && factsFromSessionTrain(requested, { outcome, collection: binding }, now).length === 0, `${label} → malformed + zero facts`, outcome);
663
+ }
664
+ const available = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '有' })], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
665
+ assert(available.kind === 'recognized-nonempty' && available.trains.length === 1 && hasRecognizedAvailableSeat(available.trains[0]), 'counterexample:valid available row → recognized row + closed-set seat');
666
+ const hitFacts = factsFromSessionTrain(requested, { outcome: available, collection: binding }, now);
667
+ assert(hitFacts.length === 1 && hitFacts[0].kind === 'train' && hitFacts[0].bookability === 'bookable_exact_date' && !('price' in hitFacts[0]), 'valid available row → one bookable train fact, price absent');
668
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, response: undefined } }, now).length === 0, 'hit-shaped outcome without captured response URL → zero facts');
669
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, response: { ...binding.response, url: binding.response.url.replace('SHH', 'SHA') } } }, now).length === 0, 'hit-shaped outcome with mismatched response URL → zero facts');
670
+ const empty = parseLeftTicketQueryResult('{"data":{"result":[]}}', entry.url ?? '');
671
+ assert(empty.kind === 'recognized-empty', 'counterexample:explicit data.result=[] → recognized-empty');
672
+ assert(factsFromSessionTrain(requested, { outcome: empty, collection: { ...binding, batchId: 'fixture-empty-batch', queryId: 'session:12306-train:fixture-empty-batch' } }, now).length === 1, 'genuine empty alone → one negative fact');
673
+ assert(factsFromSessionTrain(requested, { outcome: empty, collection: { ...binding, response: { ...binding.response, url: binding.response.url.replace('2026-12-01', '2026-12-02') } } }, now).length === 0, 'recognized-empty with mismatched response date → zero facts');
674
+ const malformedBodies = [
675
+ ['malformed JSON', 'not json'],
676
+ ['malformed root', '123'],
677
+ ['malformed result', '{"data":{"result":{}}}'],
678
+ ];
679
+ for (const [label, body] of malformedBodies) {
680
+ const outcome = parseLeftTicketQueryResult(body, entry.url ?? '');
681
+ assert(outcome.kind === 'malformed' && factsFromSessionTrain(requested, { outcome, collection: binding }, now).length === 0, `${label} → typed malformed + zero facts`, outcome);
682
+ }
683
+ const badRow = makeRow({ 30: '有' }).split('|').slice(1).join('|');
684
+ const shortRow = makeRow({ 30: '有' }).split('|').slice(0, 20).join('|');
685
+ const mixedOutcome = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '有' }), badRow], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
686
+ assert(mixedOutcome.kind === 'malformed' && factsFromSessionTrain(requested, { outcome: mixedOutcome, collection: binding }, now).length === 0, 'mixed valid/malformed batch → fail closed, zero candidates');
687
+ for (const [label, row] of [['nonobject row', null], ['short row', shortRow]]) {
688
+ const body = JSON.stringify({ data: { result: [row], map: { SHH: '上海', KMM: '昆明' } } });
689
+ const outcome = parseLeftTicketQueryResult(body, entry.url ?? '');
690
+ assert(outcome.kind === 'malformed' && factsFromSessionTrain(requested, { outcome, collection: binding }, now).length === 0, `${label} → malformed + zero facts`);
691
+ }
692
+ const transport = { outcome: { kind: 'transport-runtime-error', reason: 'fixture transport error' }, collection: undefined };
693
+ assert(factsFromSessionTrain(requested, transport, now).length === 0, 'transport/runtime error → zero facts');
694
+ const yNoSeat = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '0', 31: '无', 32: '--' })], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
695
+ assert(yNoSeat.kind === 'recognized-nonempty' && factsFromSessionTrain(requested, { outcome: yNoSeat, collection: binding }, now).length === 0, 'canWebBuy=Y but no available seat → zero positive/negative facts');
696
+ const incoherentBuy = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '有' }, 'N')], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
697
+ assert(incoherentBuy.kind === 'recognized-nonempty' && factsFromSessionTrain(requested, { outcome: incoherentBuy, collection: binding }, now).length === 0, 'seat available but incoherent buy flag → zero facts');
698
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, requested: { ...requested, from: '南京' } } }, now).length === 0, 'route mismatch binding → zero facts');
699
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, requested: { ...requested, date: '2026-12-02' } } }, now).length === 0, 'date mismatch binding → zero facts');
700
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, queryId: 'session:12306-train:other-batch' } }, now).length === 0, 'mismatched caller query identity → zero facts');
701
+ const priorOriginDate = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '有' }, 'Y', '20261130')], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
702
+ assert(priorOriginDate.kind === 'recognized-nonempty' && priorOriginDate.trains[0].startTrainDate === '2026-11-30'
703
+ && factsFromSessionTrain(requested, { outcome: priorOriginDate, collection: binding }, now).length === 1, 'startTrainDate 前一日且 passenger query date 命中 → 保留并产出事实');
704
+ const staleBinding = { ...binding, fetchedAt: new Date(now.getTime() - TRAIN_FACT_MAX_AGE_MS - 1).toISOString() };
705
+ const futureBinding = { ...binding, fetchedAt: new Date(now.getTime() + 1).toISOString() };
706
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: staleBinding }, now).length === 0 && factsFromSessionTrain(requested, { outcome: available, collection: futureBinding }, now).length === 0, `stale/future fetchedAt → zero facts (${TRAIN_FACT_FRESHNESS_RULE})`);
707
+ // L2c. Actual CDP collector seam:body and the URL come from the same response.
708
+ const savedTrainTransport = process.env.GOTRY_SESSION_TRANSPORT;
709
+ const cdpResponse = (body, url) => async () => {
710
+ let onResponse;
711
+ const page = {
712
+ on: (event, handler) => { if (event === 'response')
713
+ onResponse = handler; },
714
+ goto: async () => { await onResponse?.({ url: () => url, text: async () => body }); },
715
+ title: async () => '',
716
+ content: async () => '',
717
+ };
718
+ return {
719
+ ok: true,
720
+ browser: {},
721
+ page: page,
722
+ guard: { blockedCount: () => 0, requestCount: () => 1 },
723
+ close: async () => { },
724
+ };
725
+ };
726
+ try {
727
+ process.env.GOTRY_SESSION_TRANSPORT = 'cdp';
728
+ __resetRateLimiterForTest();
729
+ __setTrainSessionTransportForTest(cdpResponse(JSON.stringify({ data: { result: [makeRow({ 30: '有' }, 'Y', '20261130')], map: { SHH: '上海', KMM: '昆明' } } }), 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM'));
730
+ const cdpControl = await sessionTrainSearch({ from: '上海', to: '昆明', date: '2026-12-01', timeoutMs: 10 });
731
+ const cdpNow = new Date();
732
+ assert(cdpControl.collection?.response.url.includes('from_station=SHH') === true
733
+ && factsFromSessionTrain(requested, cdpControl, cdpNow).length === 1, 'CDP collector exact response URL + previous origin date → one fact');
734
+ __resetRateLimiterForTest();
735
+ __setTrainSessionTransportForTest(cdpResponse(JSON.stringify({ data: { result: [makeRow({ 30: '有' })], map: { SHH: '上海', KMM: '昆明' } } }), 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-03&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM'));
736
+ const cdpWrongRoute = await sessionTrainSearch({ from: '北京', to: '上海', date: '2026-12-03', timeoutMs: 10 });
737
+ assert(cdpWrongRoute.collection === undefined && factsFromSessionTrain({ from: '北京', to: '上海', date: '2026-12-03' }, cdpWrongRoute, now).length === 0, 'CDP wrong-route response URL → no collection/facts');
738
+ __resetRateLimiterForTest();
739
+ __setTrainSessionTransportForTest(cdpResponse('{"data":{"result":[]}}', 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM'));
740
+ const cdpWrongDateEmpty = await sessionTrainSearch({ from: '上海', to: '昆明', date: '2026-12-02', timeoutMs: 10 });
741
+ assert(cdpWrongDateEmpty.outcome.kind === 'recognized-empty' && cdpWrongDateEmpty.collection === undefined
742
+ && factsFromSessionTrain({ from: '上海', to: '昆明', date: '2026-12-02' }, cdpWrongDateEmpty, now).length === 0, 'CDP wrong-date recognized-empty → no negative fact');
743
+ }
744
+ finally {
745
+ __setTrainSessionTransportForTest(null);
746
+ if (savedTrainTransport === undefined)
747
+ delete process.env.GOTRY_SESSION_TRANSPORT;
748
+ else
749
+ process.env.GOTRY_SESSION_TRANSPORT = savedTrainTransport;
750
+ }
751
+ // L3 闸面:过去日期在工具层拦截(cooldown 链路前)
752
+ __resetRateLimiterForTest();
753
+ const e1 = await sessionTrainSearch({ from: '不在码表', to: '也不在', date: '2026-12-01' });
754
+ assert(e1.ok === false && e1.verdict === 'error' && /fromStationTelecode/.test(e1.error ?? ''), '未收录电报码 → error + 发现路径指引', e1);
755
+ const e2 = await sessionTrainSearch({ from: '上海', to: '昆明', date: '2026-12-01' });
756
+ assert(e2.ok === false && e2.verdict === 'cooldown', '节律闸:同站点 30s 内第二调 → cooldown(不发起导航)', e2);
757
+ }
758
+ // ---------------------------------------------------------------------------
759
+ // M. Dida 供应商门户会话面(2026-09-09 实装;entry 守域/信封走形/形状签名/闸面;
760
+ // 纯函数 + transport 前短路,离线确定性——扩展桥不在测试里拉起)
761
+ // ---------------------------------------------------------------------------
762
+ console.log('M. Dida 门户(entry 守域 + 信封走形 + 闸面)');
763
+ {
764
+ // M1 entry:默认 find 页;覆盖 URL 必须落在 portal.dida.com 域内
765
+ const d1 = buildDidaEntryUrl();
766
+ assert(d1.ok && d1.url === 'https://portal.dida.com/hotel/find', 'entry 默认=find 页', d1);
767
+ const d2 = buildDidaEntryUrl({ entryUrl: 'https://portal.dida.com/hotel/find?city=Bangkok' });
768
+ assert(d2.ok && d2.url === 'https://portal.dida.com/hotel/find?city=Bangkok', 'entry 覆盖(域内)放行', d2);
769
+ const d3 = buildDidaEntryUrl({ entryUrl: 'https://evil.example.com/hotel/find' });
770
+ assert(!d3.ok, 'entry 覆盖(域外)拒绝——fail-closed', d3);
771
+ const d4 = buildDidaEntryUrl({ entryUrl: 'http://portal.dida.com/hotel/find' });
772
+ assert(!d4.ok, 'entry 覆盖(http 明文)拒绝', d4);
773
+ // M2 走形:SearchRealTime 信封(hotel-be models.go 口径)→ 展平报价
774
+ const envelope = JSON.stringify({
775
+ Message: 'ok', Success: true, MessageCode: 20000,
776
+ Data: {
777
+ ReferenceNo: 'REF-ROOT',
778
+ HotelPriceList: [
779
+ {
780
+ Hotel: { HotelID: 24110, Name: 'Atlantis The Palm' },
781
+ RoomTypeList: [
782
+ { DidaRoomTypeID: 701, DidaRoomTypeName_CN: '海景大床房', DidaRoomTypeName_EN: 'Sea View King', RatePlanList: [
783
+ { RatePlanID: 'RP-1', Price: 1580.5, TotalPrice: 4741.5, Currency: 'CNY', MealType: 'Breakfast', BreakfastType: 'ABF', BedType: 'King', PaymentType: 'Prepay', Inventory: 3, RoomCount: 5, ReferenceNo: 'REF-A' },
784
+ { RatePlanID: 'RP-2', Price: 1710, TotalPrice: 5130, Currency: 'CNY', PaymentType: 'PayAtHotel', Inventory: 0, ReferenceNo: 'REF-B' },
785
+ ] },
786
+ ],
787
+ RoomList: [
788
+ { DidaRoomTypeID: 702, DidaRoomTypeName_EN: 'Suite', RatePlanList: [
789
+ { RatePlanID: 'RP-3', Price: 3200, TotalPrice: 9600, Currency: 'USD', ReferenceNo: 'REF-C' },
790
+ ] },
791
+ ],
792
+ },
793
+ { Hotel: { HotelID: 24111, Name: 'Burj Al Arab' }, RoomTypeList: [] },
794
+ ],
795
+ },
796
+ });
797
+ const rates = parseDidaRates(envelope);
798
+ assert(rates.length === 3, '信封走形:RoomTypeList+RoomList 逐计划展平', rates);
799
+ assert(rates[0].hotelId === '24110' && rates[0].hotelName === 'Atlantis The Palm' && rates[0].roomName === '海景大床房' && rates[0].roomNameEn === 'Sea View King', '酒店/房型字段映射(中文名优先且英文名独立透传)', rates[0]);
800
+ assert(rates[0].price === 1580.5 && rates[0].totalPrice === 4741.5 && rates[0].currency === 'CNY', '价/总价/币种', rates[0]);
801
+ assert(rates[0].inventory === 3 && rates[0].referenceNo === 'REF-A' && rates[0].paymentType === 'Prepay', '库存/引用号/支付类型', rates[0]);
802
+ assert(rates[1].ratePlanId === 'RP-2' && rates[1].inventory === 0, 'inventory=0 如实保留(不伪装可订)', rates[1]);
803
+ assert(rates[2].roomName === 'Suite' && rates[2].roomNameEn === 'Suite' && rates[2].currency === 'USD', 'RoomList 同构展平', rates[2]);
804
+ assert(parseDidaRates('not json').length === 0 && parseDidaRates('{"a":1}').length === 0 && parseDidaRates('[]').length === 0, 'malformed/无信封/裸空数组 一律返空(不抛错)');
805
+ assert(parseDidaRates(envelope, { maxItems: 2 }).length === 2, 'maxItems 截断');
806
+ // SearchCache(列表页价格面,hotel-fe#3731 卡片字段):多语言名 + 星级/地址/城市/坐标/首图
807
+ const searchCache = JSON.stringify({
808
+ Message: 'success', Success: true,
809
+ Data: {
810
+ HotelPriceList: [
811
+ {
812
+ Currency: 'CNY',
813
+ TotalPrice: 1738,
814
+ Hotel: {
815
+ HotelID: 1412723,
816
+ Name: '里乌迪拜海滩度假村 - 全包',
817
+ Name_EN: 'Hotel Riu Dubai Beach Resort - All Inclusive',
818
+ StarRating: 4,
819
+ Address: '迪拜Corniche Deira',
820
+ AddressFull: '迪拜Corniche Deira, 迪拜 (及邻近地区), 阿联酋',
821
+ CityName: '迪拜',
822
+ CountryCode: 'AE',
823
+ ChainName: 'RIU Resorts',
824
+ Latitude: 25.300407,
825
+ Longitude: 55.306625,
826
+ HotelImageList: [{ ImageCaption: 'Hotel Exterior', ImageUrl: 'https://image-cdn.didatravel.com/a.jpg' }],
827
+ },
828
+ },
829
+ { Currency: 'CNY', TotalPrice: 1200, Hotel: { HotelID: 11930, Name: '布尔迪拜瑞享酒店', Name_EN: 'Mövenpick Bur Dubai', StarRating: 5, HotelImageList: [] } },
830
+ ],
831
+ },
832
+ });
833
+ const scoped = parseDidaSearchCache(searchCache);
834
+ assert(scoped.length === 2, 'SearchCache:HotelPriceList 逐店展平', scoped.length);
835
+ assert(scoped[0].hotelName === '里乌迪拜海滩度假村 - 全包' && scoped[0].hotelNameEn === 'Hotel Riu Dubai Beach Resort - All Inclusive', '中文名优先 + 英文名并带(多语言展示)', scoped[0]);
836
+ assert(scoped[0].starRating === 4 && scoped[0].cityName === '迪拜' && scoped[0].countryCode === 'AE' && scoped[0].chainName === 'RIU Resorts', '星级/城市/国家/连锁', scoped[0]);
837
+ assert(scoped[0].address === '迪拜Corniche Deira, 迪拜 (及邻近地区), 阿联酋', '地址优先 AddressFull', scoped[0].address);
838
+ assert(scoped[0].latitude === 25.300407 && scoped[0].longitude === 55.306625, '坐标', scoped[0]);
839
+ assert(scoped[0].imageUrl === 'https://image-cdn.didatravel.com/a.jpg', '首图取 HotelImageList[0].ImageUrl', scoped[0].imageUrl);
840
+ assert(scoped[0].price === 1738 && scoped[0].totalPrice === 1738 && scoped[0].currency === 'CNY', '单价(1晚=总价)/总价/币种', scoped[0]);
841
+ assert(scoped[1].imageUrl === undefined && scoped[1].address === undefined, '缺失字段如实留空(不编造)', scoped[1]);
842
+ assert(parseDidaSearchCache(searchCache, { nights: 2 })[0].price === 869, 'nights>1 按晚均摊', parseDidaSearchCache(searchCache, { nights: 2 })[0].price);
843
+ assert(parseDidaSearchCache('not json').length === 0 && parseDidaSearchCache('{"Data":{}}').length === 0, 'malformed/无列表 返空不抛错');
844
+ // M3 形状签名(与 content-main DIDA_BODY_SIG_RE 逐字对账)
845
+ assert(looksLikeDidaRatesBody('"HotelPriceList":[]'), '签名命中 HotelPriceList');
846
+ assert(looksLikeDidaRatesBody('"RatePlanList":[]'), '签名命中 RatePlanList');
847
+ assert(!looksLikeDidaRatesBody(''), '空体不命中');
848
+ assert(!looksLikeDidaRatesBody('x'.repeat(2_000_001)), '超上限不命中');
849
+ // M4 闸面(transport 前短路):entry 域外 → error;同站点二调 → cooldown
850
+ __resetRateLimiterForTest();
851
+ const q1 = await sessionDidaSearch({ entryUrl: 'https://evil.example.com/x' });
852
+ assert(q1.ok === false && q1.verdict === 'error', 'entry 域外 → error(不发起导航)', q1);
853
+ const q2 = await sessionDidaSearch({});
854
+ assert(q2.ok === false && q2.verdict === 'cooldown', '节律闸:30s 内二调 → cooldown', q2);
855
+ assert(typeof didaLoginHint() === 'string' && /自动登录/.test(didaLoginHint()), '登录指引指向 hotel-be portal 跳板(账密永不经 gotry)');
856
+ }
857
+ if (process.env.GOTRY_SESSION_TEST_FORCE_FAILURE === '1') {
858
+ assert(false, 'forced session failure propagation proof');
859
+ }
860
+ console.log(`\nSESSION P1: ${pass} pass, ${fail} fail`);
861
+ if (fail > 0)
862
+ process.exitCode = 1;