@wenathlan/devthink 2.0.12 → 2.0.14

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 (444) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/accounts.ts +2087 -0
  3. package/agent.ts +2331 -0
  4. package/antigravity.ts +2139 -0
  5. package/apifreeze.ts +4146 -0
  6. package/auth.ts +642 -0
  7. package/background.ts +16837 -0
  8. package/bridge.ts +2080 -0
  9. package/build.ts +54 -0
  10. package/bun.ts +26 -0
  11. package/capture.ts +1614 -0
  12. package/checksecrets.ts +72 -0
  13. package/cjs.ts +9 -0
  14. package/cli.ts +3500 -0
  15. package/commands.ts +1101 -0
  16. package/companion.ts +450 -0
  17. package/compatibility.ts +37 -0
  18. package/config.ts +3137 -0
  19. package/constants.ts +943 -0
  20. package/core.ts +1473 -0
  21. package/crossbrowser.ts +1359 -0
  22. package/dashdone.ts +605 -0
  23. package/data.ts +943 -0
  24. package/database.ts +133 -0
  25. package/debug.ts +850 -0
  26. package/deno.ts +27 -0
  27. package/devthink.ts +707 -0
  28. package/dist/accounts.d.ts +409 -0
  29. package/dist/agent.d.ts +837 -0
  30. package/dist/antigravity.d.ts +380 -0
  31. package/dist/apifreeze.d.ts +113 -0
  32. package/dist/auth.d.ts +224 -0
  33. package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
  34. package/dist/capture.d.ts +595 -0
  35. package/dist/cjs.d.ts +6 -0
  36. package/{cli.js → dist/cli.js} +2 -2
  37. package/dist/commands.d.ts +310 -0
  38. package/dist/compatibility.d.ts +17 -0
  39. package/dist/config.d.ts +1853 -0
  40. package/dist/constants.d.ts +637 -0
  41. package/dist/core.d.ts +361 -0
  42. package/dist/data.d.ts +330 -0
  43. package/dist/database.d.ts +24 -0
  44. package/dist/debug.d.ts +270 -0
  45. package/dist/engine.d.ts +74 -0
  46. package/dist/environments.d.ts +244 -0
  47. package/dist/evidence.d.ts +277 -0
  48. package/dist/export.d.ts +311 -0
  49. package/dist/fingerprint.d.ts +594 -0
  50. package/dist/flow.d.ts +300 -0
  51. package/dist/gates.d.ts +103 -0
  52. package/dist/identity.d.ts +51 -0
  53. package/dist/ids.d.ts +7 -0
  54. package/dist/llm.d.ts +652 -0
  55. package/dist/models.d.ts +1807 -0
  56. package/dist/net.d.ts +325 -0
  57. package/dist/neutral.d.ts +41 -0
  58. package/dist/oauth.d.ts +703 -0
  59. package/dist/page.d.ts +1166 -0
  60. package/dist/perf.d.ts +481 -0
  61. package/dist/plan.d.ts +134 -0
  62. package/dist/project.d.ts +395 -0
  63. package/dist/providers.d.ts +37 -0
  64. package/dist/quota.d.ts +453 -0
  65. package/dist/recovery.d.ts +621 -0
  66. package/dist/request.d.ts +1492 -0
  67. package/dist/run.d.ts +779 -0
  68. package/dist/runtime.d.ts +212 -0
  69. package/dist/search.d.ts +375 -0
  70. package/dist/security.d.ts +534 -0
  71. package/dist/serve.d.ts +136 -0
  72. package/dist/session.d.ts +296 -0
  73. package/dist/storage.d.ts +53 -0
  74. package/dist/streaming.d.ts +1119 -0
  75. package/dist/swarm.d.ts +328 -0
  76. package/dist/tools.d.ts +170 -0
  77. package/dist/types.d.ts +8357 -0
  78. package/dist/utils.d.ts +82 -0
  79. package/dist/version.d.ts +7 -0
  80. package/dist/versionregistry.d.ts +406 -0
  81. package/dist/views.d.ts +579 -0
  82. package/dist/workbenchsession.d.ts +69 -0
  83. package/dist/workflow.d.ts +649 -0
  84. package/docs/readme.md +92 -0
  85. package/docs/schemas/antigravity.json +192 -0
  86. package/engine.ts +1524 -0
  87. package/environments.ts +1005 -0
  88. package/evidence.ts +643 -0
  89. package/export.ts +889 -0
  90. package/fingerprint.ts +1458 -0
  91. package/flow.ts +1190 -0
  92. package/gates.ts +307 -0
  93. package/gateway.ts +34 -0
  94. package/hardening.ts +191 -0
  95. package/headless.ts +369 -0
  96. package/http.ts +2110 -0
  97. package/identity.ts +246 -0
  98. package/ids.ts +26 -0
  99. package/index.ts +369 -0
  100. package/llm.ts +2682 -0
  101. package/mcp.ts +1835 -0
  102. package/{memory.js → memory.ts} +3834 -2310
  103. package/models.ts +3805 -0
  104. package/modes.ts +43 -0
  105. package/net.ts +1214 -0
  106. package/neutral.ts +362 -0
  107. package/node.ts +26 -0
  108. package/oauth.ts +2216 -0
  109. package/offscreen.ts +170 -0
  110. package/pack.ts +956 -0
  111. package/package.json +175 -82
  112. package/page.ts +7824 -0
  113. package/pagebridge.ts +1116 -0
  114. package/perf.ts +957 -0
  115. package/plan.ts +672 -0
  116. package/plugin.ts +76 -0
  117. package/{policy.js → policy.ts} +5073 -4679
  118. package/prisma.config.ts +27 -0
  119. package/progress.ts +1308 -0
  120. package/project.ts +1291 -0
  121. package/protocol.ts +4187 -0
  122. package/providers.ts +246 -0
  123. package/quota.ts +1887 -0
  124. package/recovery.ts +2433 -0
  125. package/request.ts +4482 -0
  126. package/run.ts +2135 -0
  127. package/runtime.ts +526 -0
  128. package/search.ts +1760 -0
  129. package/security.ts +1673 -0
  130. package/serve.ts +419 -0
  131. package/server.ts +5538 -0
  132. package/session.ts +1162 -0
  133. package/storage.ts +182 -0
  134. package/streaming.ts +4101 -0
  135. package/swarm.ts +884 -0
  136. package/sync.ts +56 -0
  137. package/tests/agentcert.test.ts +128 -0
  138. package/tests/agentfleet.test.ts +214 -0
  139. package/tests/agentreview.test.ts +241 -0
  140. package/tests/agentstream.test.ts +335 -0
  141. package/tests/agentwork.test.ts +356 -0
  142. package/tests/apifreeze.test.ts +290 -0
  143. package/tests/apimap.test.ts +112 -0
  144. package/tests/approvalgate.test.ts +175 -0
  145. package/tests/artifactmanifest.test.ts +204 -0
  146. package/tests/attentionfeed.test.ts +163 -0
  147. package/tests/backgroundruns.test.ts +100 -0
  148. package/tests/batchscheduling.test.ts +131 -0
  149. package/tests/blackboard.test.ts +238 -0
  150. package/tests/bridge.test.ts +1088 -0
  151. package/tests/browsercoverage.test.ts +134 -0
  152. package/tests/browserpolyfills.test.ts +124 -0
  153. package/tests/build.mjs +1260 -0
  154. package/tests/clientauth.test.ts +223 -0
  155. package/tests/clitools.test.ts +578 -0
  156. package/tests/confirmgates.test.ts +190 -0
  157. package/tests/containerpack.test.ts +143 -0
  158. package/tests/controlflow.test.ts +2120 -0
  159. package/tests/coordination.test.ts +345 -0
  160. package/tests/copyscreen.test.ts +139 -0
  161. package/tests/core.test.ts +417 -0
  162. package/tests/costcert.test.ts +95 -0
  163. package/tests/crossbrowser.test.ts +115 -0
  164. package/tests/cspaudit.test.ts +79 -0
  165. package/tests/dashdone.test.ts +615 -0
  166. package/tests/datagrid.test.ts +137 -0
  167. package/tests/doccheck.test.ts +107 -0
  168. package/tests/dombatch.test.ts +109 -0
  169. package/tests/emulation.test.ts +282 -0
  170. package/tests/environments.test.ts +259 -0
  171. package/tests/evidenceviews.test.ts +88 -0
  172. package/tests/exporttools.test.ts +185 -0
  173. package/tests/firefoxprep.test.ts +134 -0
  174. package/tests/flowlibrary.test.ts +242 -0
  175. package/tests/flowrun.test.ts +303 -0
  176. package/tests/forensics.test.ts +439 -0
  177. package/tests/gateway.test.ts +939 -0
  178. package/tests/headlesslib.test.ts +171 -0
  179. package/tests/httpstream.test.ts +277 -0
  180. package/tests/immutablelog.test.ts +157 -0
  181. package/tests/inboundguard.test.ts +177 -0
  182. package/tests/lazyload.test.ts +72 -0
  183. package/tests/librarymodes.test.ts +623 -0
  184. package/tests/llm.test.ts +696 -0
  185. package/tests/manifest.test.ts +226 -0
  186. package/tests/maskinputs.test.ts +150 -0
  187. package/tests/matrixverify.test.ts +111 -0
  188. package/tests/mavenpack.test.ts +75 -0
  189. package/tests/mcpserver.test.ts +704 -0
  190. package/tests/memory.test.ts +4155 -0
  191. package/tests/memorycare.test.ts +230 -0
  192. package/tests/migrateplan.test.ts +349 -0
  193. package/tests/minimization.test.ts +414 -0
  194. package/tests/modelroute.test.ts +112 -0
  195. package/tests/multiagent.test.ts +440 -0
  196. package/tests/nativebridge.test.ts +807 -0
  197. package/tests/navigation.test.ts +284 -0
  198. package/tests/nugetpack.test.ts +83 -0
  199. package/tests/orchestration.test.ts +449 -0
  200. package/tests/originpolicy.test.ts +405 -0
  201. package/tests/outputcompare.test.ts +97 -0
  202. package/tests/package.test.ts +91 -0
  203. package/tests/pagebridge.test.ts +5909 -0
  204. package/tests/pentest.test.ts +75 -0
  205. package/tests/perfrecords.test.ts +257 -0
  206. package/tests/permdiff.test.ts +85 -0
  207. package/tests/phishguard.test.ts +128 -0
  208. package/tests/pickerviews.test.ts +155 -0
  209. package/tests/pipelines.test.ts +381 -0
  210. package/tests/planlint.test.ts +240 -0
  211. package/tests/planreview.test.ts +383 -0
  212. package/tests/platformtargets.test.ts +56 -0
  213. package/tests/policy.test.ts +8284 -0
  214. package/tests/portability.test.ts +96 -0
  215. package/tests/progress.test.ts +1036 -0
  216. package/tests/promptlibrary.test.ts +116 -0
  217. package/tests/protocol.test.ts +4793 -0
  218. package/tests/publishingpipeline.test.ts +314 -0
  219. package/tests/quickactions.test.ts +164 -0
  220. package/tests/readiness.test.ts +78 -0
  221. package/tests/recipes.test.ts +163 -0
  222. package/tests/redactshots.test.ts +216 -0
  223. package/tests/registernvidiakeys.ts +51 -0
  224. package/tests/relayserve.test.ts +304 -0
  225. package/tests/releasenotes.test.ts +276 -0
  226. package/tests/resilience.test.ts +467 -0
  227. package/tests/resourceaware.test.ts +187 -0
  228. package/tests/runbudget.test.ts +125 -0
  229. package/tests/runhistory.test.ts +299 -0
  230. package/tests/runreplay.test.ts +113 -0
  231. package/tests/runresume.test.ts +184 -0
  232. package/tests/runstate.test.ts +288 -0
  233. package/tests/runtimeadapters.test.ts +251 -0
  234. package/tests/safariskeleton.test.ts +115 -0
  235. package/tests/sandboxframe.test.ts +196 -0
  236. package/tests/sbom.test.ts +105 -0
  237. package/tests/secretvault.test.ts +188 -0
  238. package/tests/selcache.test.ts +111 -0
  239. package/tests/sessioninterface.test.ts +791 -0
  240. package/tests/sessions.test.ts +591 -0
  241. package/tests/siteprefs.test.ts +228 -0
  242. package/tests/snapshotdelta.test.ts +165 -0
  243. package/tests/statusviews.test.ts +237 -0
  244. package/tests/stepmeter.test.ts +200 -0
  245. package/tests/streamparse.test.ts +163 -0
  246. package/tests/stylepruning.test.ts +90 -0
  247. package/tests/surfaces.test.ts +282 -0
  248. package/tests/sweep.test.ts +78 -0
  249. package/tests/syncbridge.test.ts +164 -0
  250. package/tests/taskqueue.test.ts +206 -0
  251. package/tests/toolcalls.test.ts +336 -0
  252. package/tests/toolcatalog.test.ts +201 -0
  253. package/tests/torture-agent.test.ts +706 -0
  254. package/tests/torture-auth.test.ts +461 -0
  255. package/tests/torture-commands.test.ts +895 -0
  256. package/tests/torture-data.test.ts +923 -0
  257. package/tests/torture-environments.test.ts +285 -0
  258. package/tests/torture-export.test.ts +263 -0
  259. package/tests/torture-http.test.ts +1379 -0
  260. package/tests/torture-llm.test.ts +808 -0
  261. package/tests/torture-memory.test.ts +1084 -0
  262. package/tests/torture-plan.test.ts +829 -0
  263. package/tests/torture-run.test.ts +626 -0
  264. package/tests/torture-session.test.ts +928 -0
  265. package/tests/torture-swarm.test.ts +1245 -0
  266. package/tests/torture-tools.test.ts +964 -0
  267. package/tests/torture-workflow.test.ts +2354 -0
  268. package/tests/tourviews.test.ts +52 -0
  269. package/tests/transparency.test.ts +147 -0
  270. package/tests/trigger.test.ts +941 -0
  271. package/tests/v1sunset.test.ts +428 -0
  272. package/tests/virtlist.test.ts +94 -0
  273. package/tests/vision.test.ts +540 -0
  274. package/tests/vsixpack.test.ts +145 -0
  275. package/tests/webapi.test.ts +523 -0
  276. package/tests/workflow.test.ts +1785 -0
  277. package/tests/workfloweditor.test.ts +759 -0
  278. package/tests/xpipack.test.ts +128 -0
  279. package/tools.ts +734 -0
  280. package/types.ts +9908 -0
  281. package/umd.ts +9 -0
  282. package/utils.ts +249 -0
  283. package/version.ts +8 -0
  284. package/versionregistry.ts +1310 -0
  285. package/views.ts +2153 -0
  286. package/vitest.config.ts +12 -0
  287. package/web/App.tsx +78 -0
  288. package/web/README.md +47 -0
  289. package/web/capacitor.config.ts +46 -0
  290. package/web/const.ts +19 -0
  291. package/web/control.shell.tsx +66 -0
  292. package/web/dashboardpage.ts +554 -0
  293. package/web/db.ts +493 -0
  294. package/web/design.html +541 -0
  295. package/web/gatewayview/Gateway.tsx +345 -0
  296. package/web/gatewayview/config.ts +1042 -0
  297. package/web/gatewayview/definition.ts +174 -0
  298. package/web/icons.ts +15 -0
  299. package/{manifest.json → web/manifest.json} +1 -1
  300. package/web/optionspage.ts +691 -0
  301. package/web/popup.ts +1651 -0
  302. package/web/schema.prisma +176 -0
  303. package/web/sidepanel.ts +7941 -0
  304. package/web/sitemanifest.json +13 -0
  305. package/web/terminal-ui.tsx +622 -0
  306. package/web/transparencypage.ts +541 -0
  307. package/web/vite.config.ts +39 -0
  308. package/web/workspace.ts +23 -0
  309. package/workbenchmemory.ts +104 -0
  310. package/workbenchsession.ts +304 -0
  311. package/workflow.ts +4495 -0
  312. package/bridge.d.ts.map +0 -1
  313. package/bridge.js +0 -1877
  314. package/bun.d.ts.map +0 -1
  315. package/bun.js +0 -52329
  316. package/caps/background.json +0 -712
  317. package/caps/cli.json +0 -360
  318. package/caps/library.json +0 -3611
  319. package/caps/mcp.json +0 -126
  320. package/caps/pagebridge.json +0 -377
  321. package/caps/popup.json +0 -51
  322. package/caps/sidepanel.json +0 -522
  323. package/checksums.txt +0 -142
  324. package/cli.d.ts.map +0 -1
  325. package/companion.d.ts.map +0 -1
  326. package/companion.js +0 -389
  327. package/crossbrowser.d.ts.map +0 -1
  328. package/crossbrowser.js +0 -1191
  329. package/dashdone.d.ts.map +0 -1
  330. package/dashdone.js +0 -368
  331. package/deno.d.ts.map +0 -1
  332. package/deno.js +0 -52325
  333. package/devthink.umd.js +0 -52346
  334. package/fixtures/bun.ts +0 -7
  335. package/fixtures/cjs.js +0 -6
  336. package/fixtures/deno.ts +0 -6
  337. package/fixtures/example-org-pagestate.json +0 -22
  338. package/fixtures/importers/automa-workflow.json +0 -18
  339. package/fixtures/importers/selenium-side.json +0 -20
  340. package/fixtures/importers/tabular-plan.csv +0 -7
  341. package/fixtures/importers/uivision-macro.json +0 -10
  342. package/fixtures/importers/v1-plan.json +0 -13
  343. package/fixtures/mcp-client.mjs +0 -14
  344. package/fixtures/pages/checkout.html +0 -192
  345. package/fixtures/pages/dashboard.html +0 -110
  346. package/fixtures/pages/feed.html +0 -113
  347. package/fixtures/pages/product-grid.html +0 -150
  348. package/fixtures/plans/form-inventory-plan.json +0 -11
  349. package/fixtures/plans/release-notes-plan.json +0 -11
  350. package/fixtures/recipes/agentscompete.json +0 -11
  351. package/fixtures/recipes/agentsforms.json +0 -11
  352. package/fixtures/recipes/agentsmonitor.json +0 -11
  353. package/fixtures/recipes/agentsreview.json +0 -29
  354. package/fixtures/recipes/agentswarm.json +0 -11
  355. package/fixtures/recipes/fillcalendar.json +0 -12
  356. package/fixtures/recipes/fillcheckout.json +0 -13
  357. package/fixtures/recipes/filldependentdropdowns.json +0 -12
  358. package/fixtures/recipes/fillfromcsv.json +0 -13
  359. package/fixtures/recipes/fillloginform.json +0 -12
  360. package/fixtures/recipes/fillmultistepwizard.json +0 -13
  361. package/fixtures/recipes/fillregistration.json +0 -12
  362. package/fixtures/recipes/fillsearchfilters.json +0 -12
  363. package/fixtures/recipes/filltypeahead.json +0 -11
  364. package/fixtures/recipes/monitoralerts.json +0 -12
  365. package/fixtures/recipes/monitoravailability.json +0 -11
  366. package/fixtures/recipes/monitornetworkquiet.json +0 -12
  367. package/fixtures/recipes/monitorpagechanges.json +0 -11
  368. package/fixtures/recipes/monitorpricechange.json +0 -11
  369. package/fixtures/recipes/monitorschedule.json +0 -12
  370. package/fixtures/recipes/scrapedynamicprices.json +0 -13
  371. package/fixtures/recipes/scrapeimagegallery.json +0 -12
  372. package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
  373. package/fixtures/recipes/scrapenestedlists.json +0 -12
  374. package/fixtures/recipes/scrapeproductgrid.json +0 -12
  375. package/fixtures/recipes/scrapereviews.json +0 -12
  376. package/fixtures/recipes/scrapesearchresults.json +0 -12
  377. package/fixtures/recipes/scrapetablepagination.json +0 -12
  378. package/fixtures/recipes/testaccessibility.json +0 -34
  379. package/fixtures/recipes/testconsoleerrors.json +0 -12
  380. package/fixtures/recipes/testdeeplinks.json +0 -12
  381. package/fixtures/recipes/testformvalidation.json +0 -13
  382. package/fixtures/recipes/testlayoutshift.json +0 -12
  383. package/fixtures/recipes/testlinkcheck.json +0 -12
  384. package/fixtures/recipes/testloadtiming.json +0 -12
  385. package/fixtures/recipes/testvisualdiff.json +0 -12
  386. package/fixtures/workflows/changelog-digest-workflow.json +0 -13
  387. package/fixtures/workflows/form-survey-workflow.json +0 -13
  388. package/gallery.json +0 -706
  389. package/hardening.d.ts.map +0 -1
  390. package/hardening.js +0 -188
  391. package/headless.d.ts.map +0 -1
  392. package/headless.js +0 -11522
  393. package/http.d.ts.map +0 -1
  394. package/http.js +0 -2165
  395. package/index.cjs +0 -54682
  396. package/index.d.ts.map +0 -1
  397. package/index.js +0 -52310
  398. package/index.neutral.js +0 -46343
  399. package/mcp.d.ts.map +0 -1
  400. package/mcp.js +0 -20515
  401. package/memory.d.ts.map +0 -1
  402. package/nativehost.template.json +0 -10
  403. package/node.cjs +0 -54686
  404. package/node.d.ts.map +0 -1
  405. package/pack.d.ts.map +0 -1
  406. package/pack.js +0 -805
  407. package/policy.d.ts.map +0 -1
  408. package/progress.d.ts.map +0 -1
  409. package/progress.js +0 -645
  410. package/protocol.d.ts.map +0 -1
  411. package/protocol.js +0 -8334
  412. package/schemas/audit.schema.json +0 -226
  413. package/schemas/capability.schema.json +0 -113
  414. package/schemas/envelope.schema.json +0 -162
  415. package/schemas/memory.schema.json +0 -51
  416. package/schemas/observation.schema.json +0 -113
  417. package/schemas/plan.schema.json +0 -208
  418. package/schemas/progress.schema.json +0 -126
  419. package/schemas/proposal.schema.json +0 -68
  420. package/schemas/session.schema.json +0 -82
  421. package/schemas/tool.schema.json +0 -94
  422. package/server.d.ts.map +0 -1
  423. package/server.js +0 -28877
  424. package/umd-example.html +0 -18
  425. package/umd.d.ts.map +0 -1
  426. /package/{bun.d.ts → dist/bun.d.ts} +0 -0
  427. /package/{cli.d.ts → dist/cli.d.ts} +0 -0
  428. /package/{companion.d.ts → dist/companion.d.ts} +0 -0
  429. /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
  430. /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
  431. /package/{deno.d.ts → dist/deno.d.ts} +0 -0
  432. /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
  433. /package/{headless.d.ts → dist/headless.d.ts} +0 -0
  434. /package/{http.d.ts → dist/http.d.ts} +0 -0
  435. /package/{index.d.ts → dist/index.d.ts} +0 -0
  436. /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
  437. /package/{memory.d.ts → dist/memory.d.ts} +0 -0
  438. /package/{node.d.ts → dist/node.d.ts} +0 -0
  439. /package/{pack.d.ts → dist/pack.d.ts} +0 -0
  440. /package/{policy.d.ts → dist/policy.d.ts} +0 -0
  441. /package/{progress.d.ts → dist/progress.d.ts} +0 -0
  442. /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
  443. /package/{server.d.ts → dist/server.d.ts} +0 -0
  444. /package/{umd.d.ts → dist/umd.d.ts} +0 -0
@@ -0,0 +1,1088 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ bridgepayload,
6
+ chateventpayload,
7
+ composeenvelope,
8
+ contractcapabilities,
9
+ envelopeacceptance,
10
+ eventpostbody,
11
+ eventstreambody,
12
+ frameof,
13
+ heartbeatevent,
14
+ minimalplandigest,
15
+ negotiatecapabilities,
16
+ parseenvelope,
17
+ parsewireframe,
18
+ planproposaleventpayload,
19
+ planrevieweventpayload,
20
+ progresseventpayload,
21
+ servercontractversion,
22
+ sessioncreatebody,
23
+ sessionjoinbody,
24
+ stableopid,
25
+ } from "../bridge.js";
26
+ import {
27
+ bridgeframerate,
28
+ bridgestatusview,
29
+ connectrelay,
30
+ disconnectrelay,
31
+ enqueuebridge,
32
+ heartbeattick,
33
+ idleexpired,
34
+ newrelayclient,
35
+ postevent,
36
+ receiveframe,
37
+ relayorigin,
38
+ replaybridgequeue,
39
+ verifyframeauth,
40
+ type relayclientstate,
41
+ type relaysocket,
42
+ type socketopen,
43
+ } from "../bridge.js";
44
+ import {
45
+ exchangebridgepairing,
46
+ livetokensof,
47
+ mintbridgepairing,
48
+ pairingcountdown,
49
+ revokeallsessions,
50
+ rotatebridgetoken,
51
+ tokenscopedkey,
52
+ verifybridgetoken,
53
+ } from "../auth.js";
54
+ import {
55
+ bridgestatuslabel,
56
+ carddecision,
57
+ chatrowsof,
58
+ decisionpayload,
59
+ reviewcardsof,
60
+ reviewgateoutcome,
61
+ sitemanifest,
62
+ widgetview,
63
+ pairingpanel,
64
+ chateventof,
65
+ planproposaleventof,
66
+ } from "../bridge.js";
67
+ import {
68
+ bridgechoices,
69
+ bridgeconsentgate,
70
+ bridgecontentgate,
71
+ bridgeframeorigingate,
72
+ bridgeheartbeatvalid,
73
+ bridgeidlewindowvalid,
74
+ bridgekillswitchgate,
75
+ bridgemembergate,
76
+ bridgepairingwindowvalid,
77
+ bridgeratecapvalid,
78
+ serverurlgate,
79
+ } from "../policy.js";
80
+ import { bridgepayloadreport } from "../protocol.js";
81
+ import type { agentplan, auditkind, bridgeeventrecord, bridgequeuerecord, bridgereviewcard } from "../types.js";
82
+ import { starttestrelay } from "./testrelay.mjs";
83
+
84
+ const now = 1_800_000_000_000;
85
+ const capabilities = contractcapabilities();
86
+
87
+ /** Builds one fake relay socket that answers every frame with the reply body the fixture names, so the connect flow reads a scripted handshake. */
88
+ function replyingsocket(reply: (envelope: Record<string, unknown>) => Record<string, unknown>): relaysocket {
89
+ const socket: relaysocket = {
90
+ send: (frame) => {
91
+ const envelope = JSON.parse(frame) as Record<string, unknown>;
92
+ setTimeout(
93
+ () =>
94
+ socket.onmessage?.(
95
+ JSON.stringify({
96
+ version: 1,
97
+ op: envelope.op,
98
+ opid: envelope.opid,
99
+ sessionid: "session-e2e",
100
+ token: "tok-e2e",
101
+ at: 1,
102
+ body: reply(envelope),
103
+ }),
104
+ ),
105
+ 0,
106
+ );
107
+ },
108
+ close: () => {
109
+ /* the fake close needs no wire */
110
+ },
111
+ };
112
+ return socket;
113
+ }
114
+
115
+ describe("servercontract envelope accept and reject per operation", () => {
116
+ it("accepts the four operation bodies and rejects every schema failure with the field that named it", () => {
117
+ expect(servercontractversion).toBe(1);
118
+ const create = composeenvelope({
119
+ op: "sessioncreate",
120
+ opid: stableopid("ext", 1),
121
+ at: now,
122
+ body: sessioncreatebody({ role: "extension", memberid: "ext", capabilities, pairingcode: "DT-TESTCODE" }),
123
+ });
124
+ expect(parseenvelope(create).op).toBe("sessioncreate");
125
+ expect(parseenvelope(create).body?.pairingcode).toBe("DT-TESTCODE");
126
+ const join = composeenvelope({
127
+ op: "sessionjoin",
128
+ opid: stableopid("site", 1),
129
+ at: now,
130
+ sessionid: "session-e2e",
131
+ token: "tok-e2e",
132
+ body: sessionjoinbody({
133
+ sessionid: "session-e2e",
134
+ role: "site",
135
+ memberid: "site",
136
+ capabilities,
137
+ pairingcode: "DT-TESTCODE",
138
+ }),
139
+ });
140
+ expect(parseenvelope(join).sessionid).toBe("session-e2e");
141
+ expect(parseenvelope(join).token).toBe("tok-e2e");
142
+ const post = composeenvelope({
143
+ op: "eventpost",
144
+ opid: stableopid("site", 2),
145
+ at: now,
146
+ sessionid: "session-e2e",
147
+ token: "tok-e2e",
148
+ body: eventpostbody({ event: chateventof("collect the pricing table", "site reviewer") }),
149
+ });
150
+ expect(parseenvelope(post).body?.kind).toBe("chat");
151
+ const stream = composeenvelope({
152
+ op: "eventstream",
153
+ opid: stableopid("site", 3),
154
+ at: now,
155
+ sessionid: "session-e2e",
156
+ token: "tok-e2e",
157
+ body: eventstreambody({ streams: ["chat", "review"], since: now - 1000 }),
158
+ });
159
+ expect(parseenvelope(stream).body?.streams).toEqual(["chat", "review"]);
160
+ for (const bad of [
161
+ { version: 0, op: "eventpost", opid: "op-x-1", at: now },
162
+ { version: 1, op: "nosuchop", opid: "op-x-1", at: now },
163
+ { version: 1, op: "eventpost", opid: " ", at: now },
164
+ { version: 1, op: "eventpost", opid: "op-x-1", at: Number.NaN },
165
+ { version: 1, op: "eventpost", opid: "op-x-1", at: now, sessionid: " " },
166
+ { version: 1, op: "eventpost", opid: "op-x-1", at: now, token: " " },
167
+ { version: 1, op: "eventpost", opid: "op-x-1", at: now, body: ["not", "an", "object"] },
168
+ "not-an-object",
169
+ null,
170
+ ]) {
171
+ const verdict = envelopeacceptance(bad);
172
+ expect(verdict.ok).toBe(false);
173
+ expect(verdict.reason ?? "").not.toBe("");
174
+ }
175
+ expect(() => parseenvelope(undefined)).toThrow(/must be a json object/);
176
+ expect(envelopeacceptance(post).ok).toBe(true);
177
+ expect(parsewireframe(frameof(post)).opid).toBe(post.opid);
178
+ expect(() => parsewireframe("not json")).toThrow(/json text/);
179
+ expect(() => stableopid(" ", 1)).toThrow(/names its sender seed/);
180
+ expect(() => stableopid("ext", 0)).toThrow(/positive whole number/);
181
+ expect(() => sessioncreatebody({ role: "extension", memberid: " ", capabilities })).toThrow(/names its member id/);
182
+ expect(() => sessionjoinbody({ role: "site", memberid: "site", capabilities })).toThrow(
183
+ /names its session id or rides its pairing code/,
184
+ );
185
+ expect(() => eventpostbody({ event: { kind: "nosuchkind" as "chat", stream: "chat", payload: {} } })).toThrow(
186
+ /outside the servercontract event types/,
187
+ );
188
+ expect(() => eventpostbody({ event: { kind: "chat", stream: " ", payload: {} } })).toThrow(
189
+ /names its multiplexed stream/,
190
+ );
191
+ expect(() => eventstreambody({ streams: [] })).toThrow(/at least one multiplexed stream/);
192
+ });
193
+ });
194
+
195
+ describe("servercontract capability negotiation and event payloads", () => {
196
+ it("negotiates the shared version with the intersected operations and events while refusing empty intersections", () => {
197
+ const negotiation = negotiatecapabilities(capabilities, {
198
+ version: 1,
199
+ operations: ["sessionjoin", "eventpost"],
200
+ events: ["chat", "planreview"],
201
+ heartbeat: true,
202
+ multiplex: true,
203
+ });
204
+ expect(negotiation.ok).toBe(true);
205
+ expect(negotiation.version).toBe(1);
206
+ expect(negotiation.operations).toEqual(["sessionjoin", "eventpost"]);
207
+ expect(negotiation.events).toEqual(["chat", "planreview"]);
208
+ expect(negotiation.heartbeat).toBe(true);
209
+ expect(negotiation.multiplex).toBe(true);
210
+ expect(negotiatecapabilities(capabilities, { version: 1, operations: [], events: ["chat"] }).ok).toBe(false);
211
+ expect(negotiatecapabilities(capabilities, { version: 1, operations: ["eventpost"], events: [] }).ok).toBe(false);
212
+ expect(negotiatecapabilities(capabilities, { version: 0, operations: ["eventpost"], events: ["chat"] }).ok).toBe(
213
+ false,
214
+ );
215
+ expect(
216
+ negotiatecapabilities(
217
+ { ...capabilities, heartbeat: false },
218
+ { version: 1, operations: ["eventpost"], events: ["chat"], heartbeat: true },
219
+ ).heartbeat,
220
+ ).toBe(false);
221
+ const chat = chateventpayload({ text: " collect the pricing table ", from: "site reviewer" });
222
+ expect(chat).toEqual({ text: "collect the pricing table", from: "site reviewer" });
223
+ expect(() => chateventpayload({ text: " ", from: "site" })).toThrow(/carries its task text/);
224
+ const card: bridgereviewcard = {
225
+ proposalid: "plan-1",
226
+ title: "Collect the pricing table",
227
+ steps: [
228
+ { id: "s1", kind: "readtext", origin: "https://shop.example", sensitive: false },
229
+ { id: "s2", kind: "submitticket", origin: "https://shop.example", sensitive: true },
230
+ ],
231
+ at: now,
232
+ };
233
+ const proposal = planproposaleventpayload(card);
234
+ expect(proposal.steps).toEqual(card.steps);
235
+ expect(() => planproposaleventpayload({ ...card, title: " " })).toThrow(/names its plan title/);
236
+ const review = planrevieweventpayload({
237
+ proposalid: "plan-1",
238
+ decision: "changes",
239
+ by: "site reviewer",
240
+ note: "skip the checkout",
241
+ at: now,
242
+ });
243
+ expect(review.decision).toBe("changes");
244
+ expect(() =>
245
+ planrevieweventpayload({ proposalid: "plan-1", decision: "later" as "approved", by: "site", at: now }),
246
+ ).toThrow(/approved, changes or refused/);
247
+ expect(progresseventpayload({ stepid: "s1", status: "done" })).toEqual({ stepid: "s1", status: "done" });
248
+ expect(heartbeatevent(now)).toEqual({
249
+ kind: "progress",
250
+ stream: "heartbeat",
251
+ payload: { stepid: "heartbeat", status: "alive" },
252
+ });
253
+ const plan: agentplan = {
254
+ id: "plan-1",
255
+ objective: "Collect the pricing table",
256
+ origin: "https://shop.example",
257
+ steps: [
258
+ { id: "s1", kind: "readtext", target: "main", state: "pending" } as never,
259
+ { id: "s2", kind: "readforms", target: "form", state: "pending" } as never,
260
+ ],
261
+ createdat: now,
262
+ expiresat: now + 60000,
263
+ state: "approved",
264
+ };
265
+ const digest = minimalplandigest(plan, { s1: "done" });
266
+ expect(digest.title).toBe("Collect the pricing table");
267
+ expect(digest.steps).toEqual([
268
+ { id: "s1", kind: "readtext", status: "done" },
269
+ { id: "s2", kind: "readforms", status: "pending" },
270
+ ]);
271
+ expect(sitemanifest().servercontract).toBe(1);
272
+ expect(sitemanifest().capabilities.operations).toContain("eventstream");
273
+ });
274
+ });
275
+
276
+ describe("socketrelay connect, reconnect and backoff", () => {
277
+ it("connects through the seam, retries the failed opens with the exponential backoff waits and rotates the token on every retry", async () => {
278
+ let failures = 2;
279
+ let rotations = 0;
280
+ const sleeps: number[] = [];
281
+ const open: socketopen = (url) => {
282
+ void url;
283
+ failures -= 1;
284
+ if (failures >= 0) return Promise.reject(new Error("connection refused"));
285
+ return Promise.resolve(replyingsocket(() => ({ sessionid: "session-e2e", token: "tok-e2e", capabilities })));
286
+ };
287
+ const state = newrelayclient({ url: "wss://relay.example", memberid: "ext", role: "extension", capabilities, now });
288
+ const connected = await connectrelay({
289
+ state,
290
+ open,
291
+ attempts: 3,
292
+ backoffbase: 100,
293
+ backoffceiling: 400,
294
+ sleep: async (ms) => {
295
+ sleeps.push(ms);
296
+ },
297
+ rotate: async () => {
298
+ rotations += 1;
299
+ return "tok-rotated";
300
+ },
301
+ now: () => now,
302
+ });
303
+ expect(connected.error).toBeUndefined();
304
+ expect(connected.state.state).toBe("connected");
305
+ expect(connected.state.sessionid).toBe("session-e2e");
306
+ expect(connected.state.token).toBe("tok-e2e");
307
+ expect(connected.state.attempts).toBe(2);
308
+ expect(rotations).toBe(2);
309
+ expect(sleeps).toEqual([100, 200]);
310
+ const refused = await connectrelay({
311
+ state: newrelayclient({ url: "wss://relay.example", memberid: "ext", role: "extension", capabilities, now }),
312
+ open: () => Promise.reject(new Error("connection refused")),
313
+ attempts: 3,
314
+ backoffbase: 10,
315
+ sleep: async () => {
316
+ /* the fake sleep waits nothing */
317
+ },
318
+ now: () => now,
319
+ });
320
+ expect(refused.socket).toBeUndefined();
321
+ expect(refused.error).toContain("connection refused");
322
+ expect(refused.state.state).toBe("closed");
323
+ const disabled = await connectrelay({
324
+ state: newrelayclient({ url: "", memberid: "ext", role: "extension", capabilities, now }),
325
+ open,
326
+ now: () => now,
327
+ });
328
+ expect(disabled.state.state).toBe("idle");
329
+ expect(disabled.error).toContain("no relay url is configured");
330
+ expect(relayorigin("wss://relay.example:8443/path")).toBe("https://relay.example:8443");
331
+ expect(relayorigin("ws://127.0.0.1:8080")).toBe("http://127.0.0.1:8080");
332
+ expect(relayorigin("https://relay.example")).toBe("");
333
+ expect(() =>
334
+ newrelayclient({ url: "https://relay.example", memberid: "ext", role: "extension", capabilities, now }),
335
+ ).toThrow(/wss or ws url/);
336
+ expect(() =>
337
+ newrelayclient({ url: "wss://relay.example", memberid: " ", role: "extension", capabilities, now }),
338
+ ).toThrow(/names its member id/);
339
+ });
340
+ });
341
+
342
+ describe("socketrelay frame authentication, multiplexing and rate caps", () => {
343
+ it("verifies the session token on every frame, routes the streams and caps the frames of one window", () => {
344
+ const state = newrelayclient({
345
+ url: "wss://relay.example",
346
+ memberid: "ext",
347
+ role: "extension",
348
+ capabilities,
349
+ sessionid: "session-e2e",
350
+ token: "tok-e2e",
351
+ now,
352
+ });
353
+ expect(
354
+ verifyframeauth(
355
+ { version: 1, op: "eventpost", opid: "op-site-1", sessionid: "session-e2e", token: "tok-e2e", at: now },
356
+ state,
357
+ ),
358
+ ).toBe(true);
359
+ expect(
360
+ verifyframeauth(
361
+ { version: 1, op: "eventpost", opid: "op-site-1", sessionid: "session-other", token: "tok-e2e", at: now },
362
+ state,
363
+ ),
364
+ ).toBe(false);
365
+ expect(
366
+ verifyframeauth(
367
+ { version: 1, op: "eventpost", opid: "op-site-1", sessionid: "session-e2e", token: "tok-wrong", at: now },
368
+ state,
369
+ ),
370
+ ).toBe(false);
371
+ const connected: relayclientstate = { ...state, state: "connected" };
372
+ const chatframe = frameof({
373
+ version: 1,
374
+ op: "eventpost",
375
+ opid: "op-site-1",
376
+ sessionid: "session-e2e",
377
+ token: "tok-e2e",
378
+ at: now,
379
+ body: eventpostbody({ event: chateventof("collect the pricing table", "site reviewer") }),
380
+ });
381
+ const received = receiveframe(connected, chatframe, now);
382
+ expect(received.rejection).toBeUndefined();
383
+ expect(received.state.streams.chat?.length).toBe(1);
384
+ expect(received.state.streams.chat?.[0]?.payload.text).toBe("collect the pricing table");
385
+ expect(received.state.acks.length).toBe(1);
386
+ const progressframe = frameof({
387
+ version: 1,
388
+ op: "eventpost",
389
+ opid: "op-site-2",
390
+ sessionid: "session-e2e",
391
+ token: "tok-e2e",
392
+ at: now,
393
+ body: eventpostbody({ event: heartbeatevent(now) }),
394
+ });
395
+ const routed = receiveframe(received.state, progressframe, now);
396
+ expect(routed.state.streams.heartbeat?.length).toBe(1);
397
+ expect(receiveframe(routed.state, "not json", now).rejection).toContain("json text");
398
+ const forged = frameof({
399
+ version: 1,
400
+ op: "eventpost",
401
+ opid: "op-site-3",
402
+ sessionid: "session-e2e",
403
+ token: "tok-wrong",
404
+ at: now,
405
+ body: eventpostbody({ event: chateventof("hello", "site") }),
406
+ });
407
+ expect(receiveframe(routed.state, forged, now).rejection).toContain("failed the frame authentication");
408
+ const rates = bridgeframerate({ rate: { used: 0, windowstartedat: now }, cap: 2, window: 1000, now });
409
+ expect(rates.allowed).toBe(true);
410
+ expect(
411
+ bridgeframerate({ rate: { used: 1, windowstartedat: now }, cap: 2, window: 1000, now: now + 500 }).allowed,
412
+ ).toBe(true);
413
+ const capped = bridgeframerate({ rate: { used: 2, windowstartedat: now }, cap: 2, window: 1000, now: now + 500 });
414
+ expect(capped.allowed).toBe(false);
415
+ expect(capped.resetsat).toBe(now + 1000);
416
+ expect(
417
+ bridgeframerate({ rate: { used: 5, windowstartedat: now }, cap: 2, window: 1000, now: now + 1000 }).allowed,
418
+ ).toBe(true);
419
+ expect(bridgeframerate({ rate: { used: 5, windowstartedat: now }, now: now + 1 }).allowed).toBe(true);
420
+ });
421
+ });
422
+
423
+ describe("sharedauth pairing, rotation and revocation", () => {
424
+ it("mints the code with the countdown, exchanges it once per origin, rotates on reconnect and revokes all sessions with one click", async () => {
425
+ const origin = "https://relay.example";
426
+ const minted = mintbridgepairing({ origin, now, lifetime: 120000 });
427
+ expect(minted.code.code).toMatch(/^DT-/);
428
+ expect(minted.code.scopes).toEqual(["browser"]);
429
+ expect(pairingcountdown(minted, now + 30000).label).toContain("1m 30s");
430
+ const expired = mintbridgepairing({ origin, now: now - 300000, lifetime: 60000 });
431
+ expect(pairingcountdown(expired, now).expired).toBe(true);
432
+ expect(pairingcountdown({ ...minted, code: { ...minted.code, usedat: now } }, now).expired).toBe(true);
433
+ expect(() => mintbridgepairing({ origin: " ", now })).toThrow(/names its relay origin/);
434
+ const wrongorigin = await exchangebridgepairing({
435
+ records: [minted],
436
+ origin: "https://other.example",
437
+ code: minted.code.code,
438
+ sessionid: "session-e2e",
439
+ now,
440
+ });
441
+ expect(wrongorigin.reason).toContain("No pending pairing code");
442
+ const late = await exchangebridgepairing({
443
+ records: [minted],
444
+ origin,
445
+ code: minted.code.code,
446
+ sessionid: "session-e2e",
447
+ now: now + 130000,
448
+ });
449
+ expect(late.reason).toContain("expired");
450
+ const exchange = await exchangebridgepairing({
451
+ records: [minted],
452
+ origin,
453
+ code: minted.code.code,
454
+ sessionid: "session-e2e",
455
+ now,
456
+ });
457
+ expect(exchange.raw).toBeDefined();
458
+ expect(exchange.record?.hash.startsWith("sha256:")).toBe(true);
459
+ expect(exchange.record?.sessionid).toBe("session-e2e");
460
+ const reused = await exchangebridgepairing({
461
+ records: [{ ...minted, code: exchange.used ?? minted.code }],
462
+ origin,
463
+ code: minted.code.code,
464
+ sessionid: "session-e2e",
465
+ now: now + 1000,
466
+ });
467
+ expect(reused.reason).toContain("already used");
468
+ const tokens = [exchange.record!];
469
+ const verified = await verifybridgetoken({ tokens, origin, sessionid: "session-e2e", raw: exchange.raw!, now });
470
+ expect(verified.ok).toBe(true);
471
+ expect(
472
+ (
473
+ await verifybridgetoken({
474
+ tokens,
475
+ origin: "https://other.example",
476
+ sessionid: "session-e2e",
477
+ raw: exchange.raw!,
478
+ now,
479
+ })
480
+ ).ok,
481
+ ).toBe(false);
482
+ expect((await verifybridgetoken({ tokens, origin, sessionid: "session-other", raw: exchange.raw!, now })).ok).toBe(
483
+ false,
484
+ );
485
+ const rotation = await rotatebridgetoken({ tokens, sessionid: "session-e2e", origin, now: now + 5000 });
486
+ expect(rotation.record.rotatedfrom).toBe(exchange.record!.id);
487
+ expect(rotation.tokens.find((token) => token.id === exchange.record!.id)?.revokedat).toBe(now + 5000);
488
+ expect(
489
+ (
490
+ await verifybridgetoken({
491
+ tokens: rotation.tokens,
492
+ origin,
493
+ sessionid: "session-e2e",
494
+ raw: exchange.raw!,
495
+ now: now + 5000,
496
+ })
497
+ ).ok,
498
+ ).toBe(false);
499
+ expect(
500
+ (
501
+ await verifybridgetoken({
502
+ tokens: rotation.tokens,
503
+ origin,
504
+ sessionid: "session-e2e",
505
+ raw: rotation.raw,
506
+ now: now + 5000,
507
+ })
508
+ ).ok,
509
+ ).toBe(true);
510
+ const revoked = revokeallsessions(rotation.tokens, now + 10000);
511
+ expect(revoked.every((token) => token.revokedat !== undefined)).toBe(true);
512
+ expect(livetokensof(revoked, origin, now + 10000).length).toBe(0);
513
+ expect(livetokensof(rotation.tokens, origin, now + 5000).length).toBe(1);
514
+ expect(tokenscopedkey(origin)).toBe("bridgetokens:https://relay.example");
515
+ expect(() => tokenscopedkey(" ")).toThrow(/names its relay origin/);
516
+ });
517
+ });
518
+
519
+ describe("bridge consent gate, kill switch, member shape and url validation", () => {
520
+ it("blocks the first connection without the recorded consent, refuses every operation under the engaged kill switch and validates the serverurl shape", () => {
521
+ expect(bridgeconsentgate({ connected: false }).allowed).toBe(false);
522
+ expect(bridgeconsentgate({ consent: false, connected: false }).allowed).toBe(false);
523
+ expect(bridgeconsentgate({ consent: true, connected: false }).allowed).toBe(true);
524
+ expect(bridgeconsentgate({ consent: false, connected: true }).allowed).toBe(true);
525
+ expect(bridgekillswitchgate({ engaged: true, operation: "pairing code mint" }).allowed).toBe(false);
526
+ expect(bridgekillswitchgate({ engaged: false, operation: "pairing code mint" }).allowed).toBe(true);
527
+ expect(bridgemembergate({ extension: 1, site: 1 }).allowed).toBe(true);
528
+ expect(bridgemembergate({ extension: 1, site: 0 }).allowed).toBe(true);
529
+ expect(bridgemembergate({ extension: 2, site: 1 }).allowed).toBe(false);
530
+ expect(bridgemembergate({ extension: 1, site: 2 }).allowed).toBe(false);
531
+ expect(serverurlgate("").allowed).toBe(true);
532
+ expect(serverurlgate("wss://relay.example").allowed).toBe(true);
533
+ expect(serverurlgate("wss://relay.example:8443/bridge").allowed).toBe(true);
534
+ expect(serverurlgate("ws://127.0.0.1:8080").allowed).toBe(true);
535
+ expect(serverurlgate("ws://relay.example").allowed).toBe(false);
536
+ expect(serverurlgate("https://relay.example").allowed).toBe(false);
537
+ expect(serverurlgate("wss://relay.example:notaport").allowed).toBe(false);
538
+ expect(serverurlgate("not a url").allowed).toBe(false);
539
+ expect(bridgepairingwindowvalid(0).allowed).toBe(false);
540
+ expect(bridgepairingwindowvalid(300000).allowed).toBe(true);
541
+ expect(bridgeidlewindowvalid(-1).allowed).toBe(false);
542
+ expect(bridgeidlewindowvalid(60000).allowed).toBe(true);
543
+ expect(bridgeheartbeatvalid(0).allowed).toBe(false);
544
+ expect(bridgeheartbeatvalid(30000).allowed).toBe(true);
545
+ expect(bridgeratecapvalid(0, 1000).allowed).toBe(false);
546
+ expect(bridgeratecapvalid(10, 0).allowed).toBe(false);
547
+ expect(bridgeratecapvalid(10, 1000).allowed).toBe(true);
548
+ expect(bridgechoices({ serverurl: "wss://relay.example", bridgeratelimit: 10, bridgeratewindow: 1000 })).toEqual({
549
+ serverurl: "wss://relay.example",
550
+ bridgeratelimit: 10,
551
+ bridgeratewindow: 1000,
552
+ });
553
+ expect(bridgechoices(undefined)).toEqual({});
554
+ });
555
+ });
556
+
557
+ describe("bridge origin checks reject unpaired senders", () => {
558
+ it("refuses frames of foreign sessions, anonymous senders and unpaired origins while the paired frame passes", () => {
559
+ const origin = "https://relay.example";
560
+ expect(
561
+ bridgeframeorigingate({ framesession: "session-e2e", sessionid: "session-e2e", origin, paired: true }).allowed,
562
+ ).toBe(true);
563
+ expect(
564
+ bridgeframeorigingate({ framesession: "session-other", sessionid: "session-e2e", origin, paired: true }).allowed,
565
+ ).toBe(false);
566
+ expect(bridgeframeorigingate({ sessionid: "session-e2e", origin, paired: false }).allowed).toBe(false);
567
+ expect(bridgeframeorigingate({ framesender: " ", sessionid: "session-e2e", origin, paired: true }).allowed).toBe(
568
+ false,
569
+ );
570
+ expect(
571
+ bridgeframeorigingate({ framesender: "site member", sessionid: "session-e2e", origin, paired: true }).allowed,
572
+ ).toBe(true);
573
+ });
574
+ });
575
+
576
+ describe("bridge data minimization keeps page content off the wire", () => {
577
+ it("holds the page content keys without the explicit consent flag, keeps the chat task text and records the bridge audit kind", () => {
578
+ const chatpass = bridgepayload("chat", { text: "collect the pricing table", from: "site" });
579
+ expect(chatpass.payload.text).toBe("collect the pricing table");
580
+ expect(chatpass.held).toEqual([]);
581
+ const proposal = bridgepayload("planproposal", {
582
+ proposalid: "plan-1",
583
+ title: "Collect the pricing table",
584
+ steps: [],
585
+ html: "<main>the whole page</main>",
586
+ pagetext: "captured text",
587
+ status: "pending",
588
+ });
589
+ expect(proposal.held.sort()).toEqual(["html", "pagetext"]);
590
+ expect(Object.keys(proposal.payload).includes("html")).toBe(false);
591
+ const consented = bridgepayload("planproposal", { html: "<main>the whole page</main>" }, true);
592
+ expect(consented.held).toEqual([]);
593
+ expect(consented.payload.html).toBe("<main>the whole page</main>");
594
+ const progress = bridgepayload("progress", { stepid: "s1", status: "done", dom: "the dom snapshot" });
595
+ expect(progress.held).toEqual(["dom"]);
596
+ expect(bridgecontentgate({ payload: { html: "x" } }).allowed).toBe(false);
597
+ expect(bridgecontentgate({ payload: { html: "x" }, pageconsent: true }).allowed).toBe(true);
598
+ expect(bridgecontentgate({ payload: { stepid: "s1", status: "done" } }).allowed).toBe(true);
599
+ const report = bridgepayloadreport({ kind: "progress", payload: { stepid: "s1", status: "done" } });
600
+ expect(report.held).toEqual([]);
601
+ expect(report.version).toBe(servercontractversion);
602
+ expect(() =>
603
+ bridgepayloadreport({
604
+ kind: "progress",
605
+ payload: { stepid: "s1", status: "done", screenshot: "data:image/png;base64,bytes" },
606
+ }),
607
+ ).toThrow(/page content never crosses the bridge/);
608
+ const kinds: auditkind[] = ["bridge", "syncbridge", "socket"];
609
+ expect(kinds).toContain("bridge");
610
+ });
611
+ });
612
+
613
+ describe("bridge offline queue buffering and replay deduplication", () => {
614
+ it("buffers the frames while the socket is down, deduplicates the operation ids and replays each frame exactly once", () => {
615
+ const down = newrelayclient({
616
+ url: "wss://relay.example",
617
+ memberid: "ext",
618
+ role: "extension",
619
+ capabilities,
620
+ sessionid: "session-e2e",
621
+ token: "tok-e2e",
622
+ now,
623
+ });
624
+ const first = postevent(down, undefined, chateventof("collect the pricing table", "site reviewer"), now);
625
+ expect(first.sent).toBe(false);
626
+ expect(first.queued).toBe(true);
627
+ expect(first.state.queue.length).toBe(1);
628
+ const duplicate = enqueuebridge(first.state, { op: "eventpost", opid: first.state.queue[0]!.opid, body: {}, now });
629
+ expect(duplicate.queue.length).toBe(1);
630
+ const second = postevent(
631
+ duplicate,
632
+ undefined,
633
+ planproposaleventof({
634
+ proposalid: "plan-1",
635
+ title: "Collect",
636
+ steps: [{ id: "s1", kind: "readtext", origin: "https://shop.example", sensitive: false }],
637
+ at: now,
638
+ }),
639
+ now + 1,
640
+ );
641
+ expect(second.state.queue.length).toBe(2);
642
+ expect(second.state.queue.filter((record) => record.sentat === undefined).length).toBe(2);
643
+ const sent: string[] = [];
644
+ const socket: relaysocket = {
645
+ send: (frame) => sent.push(frame),
646
+ close: () => {
647
+ /* the fake close needs no wire */
648
+ },
649
+ };
650
+ const reconnected: relayclientstate = { ...second.state, state: "connected" };
651
+ const replay = replaybridgequeue(reconnected, socket, now + 2);
652
+ expect(replay.replayed).toBe(2);
653
+ expect(replay.deduped).toBe(0);
654
+ expect(replay.state.queue.every((record) => record.sentat === now + 2)).toBe(true);
655
+ expect(sent.length).toBe(2);
656
+ const again = replaybridgequeue(replay.state, socket, now + 3);
657
+ expect(again.replayed).toBe(0);
658
+ expect(sent.length).toBe(2);
659
+ const { sentat: used, ...unsent } = reconnected.queue[0]!;
660
+ void used;
661
+ const duplicated: relayclientstate = {
662
+ ...replay.state,
663
+ queue: [...replay.state.queue, { ...unsent, attempts: 0 }],
664
+ };
665
+ const deduped = replaybridgequeue(duplicated, socket, now + 4);
666
+ expect(deduped.replayed).toBe(0);
667
+ expect(deduped.deduped).toBe(1);
668
+ const closed = disconnectrelay(reconnected, now + 5, "The relay session expired.");
669
+ expect(closed.state).toBe("closed");
670
+ expect(closed.closedat).toBe(now + 5);
671
+ });
672
+ });
673
+
674
+ describe("bridge heartbeat and idle expiry lifecycle", () => {
675
+ it("sends the heartbeat frame on the user interval and expires the quiet session inside the idle window", () => {
676
+ const sent: string[] = [];
677
+ const socket: relaysocket = {
678
+ send: (frame) => sent.push(frame),
679
+ close: () => {
680
+ /* the fake close needs no wire */
681
+ },
682
+ };
683
+ const state: relayclientstate = {
684
+ ...newrelayclient({
685
+ url: "wss://relay.example",
686
+ memberid: "ext",
687
+ role: "extension",
688
+ capabilities,
689
+ sessionid: "session-e2e",
690
+ token: "tok-e2e",
691
+ now,
692
+ }),
693
+ state: "connected",
694
+ };
695
+ expect(heartbeattick(state, socket, now + 999, 1000).beat).toBe(false);
696
+ const beat = heartbeattick(state, socket, now + 1000, 1000);
697
+ expect(beat.beat).toBe(true);
698
+ expect(sent.length).toBe(1);
699
+ expect(JSON.parse(sent[0]!).body.stream).toBe("heartbeat");
700
+ expect(beat.state.lastheartbeatat).toBe(now + 1000);
701
+ expect(heartbeattick(state, undefined, now + 2000, 1000).beat).toBe(false);
702
+ expect(heartbeattick(state, socket, now + 2000).beat).toBe(false);
703
+ expect(idleexpired(state, now + 29999, 30000)).toBe(false);
704
+ expect(idleexpired(state, now + 30000, 30000)).toBe(true);
705
+ expect(idleexpired(state, now + 999999)).toBe(false);
706
+ const active = receiveframe(
707
+ state,
708
+ frameof({
709
+ version: 1,
710
+ op: "eventpost",
711
+ opid: "op-site-9",
712
+ sessionid: "session-e2e",
713
+ token: "tok-e2e",
714
+ at: now + 20000,
715
+ body: eventpostbody({ event: chateventof("still here", "site") }),
716
+ }),
717
+ now + 20000,
718
+ );
719
+ expect(idleexpired(active.state, now + 49999, 30000)).toBe(false);
720
+ });
721
+ });
722
+
723
+ describe("chatbridge widget render and review decisions never execute", () => {
724
+ it("renders the chat rows and review cards, collects the decisions and maps them onto the review gate without executing anything", () => {
725
+ const events: bridgeeventrecord[] = [
726
+ {
727
+ id: "op-site-1",
728
+ sessionid: "session-e2e",
729
+ kind: "chat",
730
+ opid: "op-site-1",
731
+ stream: "chat",
732
+ payload: { text: "collect the pricing table", from: "site reviewer" },
733
+ at: now,
734
+ delivered: true,
735
+ },
736
+ {
737
+ id: "op-ext-1",
738
+ sessionid: "session-e2e",
739
+ kind: "chat",
740
+ opid: "op-ext-1",
741
+ stream: "chat",
742
+ payload: { text: "the plan proposes two steps", from: "extension" },
743
+ at: now + 1,
744
+ delivered: true,
745
+ },
746
+ {
747
+ id: "op-ext-2",
748
+ sessionid: "session-e2e",
749
+ kind: "planproposal",
750
+ opid: "op-ext-2",
751
+ stream: "review",
752
+ payload: {
753
+ proposalid: "plan-1",
754
+ title: "Collect the pricing table",
755
+ steps: [
756
+ { id: "s1", kind: "readtext", origin: "https://shop.example", sensitive: false },
757
+ { id: "s2", kind: "submitticket", origin: "https://shop.example", sensitive: true },
758
+ ],
759
+ },
760
+ at: now + 2,
761
+ delivered: true,
762
+ },
763
+ {
764
+ id: "op-site-2",
765
+ sessionid: "session-e2e",
766
+ kind: "planreview",
767
+ opid: "op-site-2",
768
+ stream: "review",
769
+ payload: { proposalid: "plan-1", decision: "approved", by: "site reviewer" },
770
+ at: now + 3,
771
+ delivered: true,
772
+ },
773
+ {
774
+ id: "op-ext-3",
775
+ sessionid: "session-e2e",
776
+ kind: "progress",
777
+ opid: "op-ext-3",
778
+ stream: "progress",
779
+ payload: { stepid: "s1", status: "done" },
780
+ at: now + 4,
781
+ delivered: true,
782
+ },
783
+ ];
784
+ const rows = chatrowsof(events, "extension");
785
+ expect(rows.length).toBe(2);
786
+ expect(rows[0]?.mine).toBe(false);
787
+ expect(rows[1]?.mine).toBe(true);
788
+ const cards = reviewcardsof(events);
789
+ expect(cards.length).toBe(1);
790
+ expect(cards[0]?.steps[1]?.sensitive).toBe(true);
791
+ const view = widgetview({ status: "connected", events, memberid: "extension" });
792
+ expect(view.rows.length).toBe(2);
793
+ expect(view.cards.length).toBe(1);
794
+ expect(view.pending).toBe(0);
795
+ const openview = widgetview({ status: "connected", events: events.slice(0, 3), memberid: "extension" });
796
+ expect(openview.pending).toBe(1);
797
+ const decision = carddecision(cards[0]!, "changes", "site reviewer", now + 5, "skip the checkout step");
798
+ expect(decision.proposalid).toBe("plan-1");
799
+ expect(() => carddecision({ ...cards[0]!, proposalid: " " }, "approved", "site", now)).toThrow(
800
+ /names its proposal/,
801
+ );
802
+ expect(() => carddecision(cards[0]!, "later" as "approved", "site", now)).toThrow(/approved, changes or refused/);
803
+ expect(decisionpayload(decision).decision).toBe("changes");
804
+ const approved = reviewgateoutcome({ proposalid: "plan-1", decision: "approved", by: "site reviewer", at: now });
805
+ expect(approved.state).toBe("approved");
806
+ expect(approved.reason).toContain("the extension stays the only executor");
807
+ expect(reviewgateoutcome(decision).state).toBe("refused");
808
+ expect(reviewgateoutcome(decision).reason).toContain("no step executes");
809
+ const pairing = pairingpanel(
810
+ mintbridgepairing({ origin: "https://relay.example", now, lifetime: 120000 }),
811
+ now + 30000,
812
+ );
813
+ expect(pairing.code).toMatch(/^DT-/);
814
+ expect(pairing.label).toContain("1m 30s");
815
+ expect(sitemanifest().name).toBe("devthink site");
816
+ });
817
+ });
818
+
819
+ describe("popup bridge states", () => {
820
+ it("reads the connected, paired, offline and disabled states with the queued frame count", () => {
821
+ const base = newrelayclient({
822
+ url: "wss://relay.example",
823
+ memberid: "ext",
824
+ role: "extension",
825
+ capabilities,
826
+ sessionid: "session-e2e",
827
+ token: "tok-e2e",
828
+ now,
829
+ });
830
+ expect(bridgestatusview({ ...base, state: "connected" }).status).toBe("connected");
831
+ expect(bridgestatusview({ ...base, state: "reconnecting" }).status).toBe("paired");
832
+ expect(bridgestatuslabel(bridgestatusview({ ...base, state: "reconnecting" }))).toContain("paired, socket offline");
833
+ expect(bridgestatuslabel(bridgestatusview({ ...base, state: "connected" }))).toContain("connected and paired");
834
+ const offline = newrelayclient({
835
+ url: "wss://relay.example",
836
+ memberid: "ext",
837
+ role: "extension",
838
+ capabilities,
839
+ now,
840
+ });
841
+ expect(bridgestatusview({ ...offline, state: "connecting" }).status).toBe("offline");
842
+ expect(bridgestatuslabel(bridgestatusview({ ...offline, state: "connecting" }))).toBe("Bridge: offline.");
843
+ const disabled = newrelayclient({ url: "", memberid: "ext", role: "extension", capabilities, now });
844
+ expect(bridgestatusview(disabled).status).toBe("disabled");
845
+ expect(bridgestatuslabel(bridgestatusview(disabled))).toContain("no relay url set");
846
+ const queued = postevent(offline, undefined, chateventof("collect the pricing table", "site reviewer"), now);
847
+ expect(bridgestatusview(queued.state).queued).toBe(1);
848
+ expect(bridgestatuslabel(bridgestatusview(queued.state))).toContain("1 queued frame");
849
+ });
850
+ });
851
+
852
+ describe("static site scan refuses serverless functions", () => {
853
+ it("asserts the site ships static file types only and no function directory exists anywhere in the repository", () => {
854
+ const root = process.cwd();
855
+ const sitedir = join(root, "web");
856
+ /* the merged site tree: the devthink web application, the extension platform folder and the gateway console folder all ship static source types the build compiles — the type ceiling refuses every executable or binary a serverless platform would need (the web tree stays zero js: the compiled artifacts live in the build output, never in the sources). svg joins the set at 2.0.14: the tauri icon source (web/icon.svg) is a static image the desktop lane derives its bundle densities from — the same static class the css sheets answer, never an executable surface */
857
+ const statictypes = new Set(["ts", "tsx", "css", "html", "json", "toml", "yaml", "yml", "md", "gitkeep", "prisma", "svg"]);
858
+ const sitefiles: string[] = [];
859
+ const walksites = (dir: string): void => {
860
+ for (const entry of readdirSync(dir)) {
861
+ if (entry === "node_modules" || entry === "dist")
862
+ continue; /* the installed toolchain and the build output never ship with the site sources */
863
+ const path = join(dir, entry);
864
+ if (statSync(path).isDirectory()) {
865
+ walksites(path);
866
+ continue;
867
+ }
868
+ sitefiles.push(path);
869
+ }
870
+ };
871
+ walksites(sitedir);
872
+ expect(sitefiles.length).toBeGreaterThan(0);
873
+ for (const file of sitefiles) {
874
+ const type = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
875
+ expect(statictypes.has(type), `${file} ships the non static file type ${type}`).toBe(true);
876
+ }
877
+ const functionnames = new Set([
878
+ "api",
879
+ "functions",
880
+ "serverless",
881
+ "netlify",
882
+ "vercel",
883
+ "edge",
884
+ ".serverless",
885
+ ".netlify",
886
+ ".vercel",
887
+ ]);
888
+ const functionfiles = new Set(["serverless.yml", "serverless.yaml", "_worker.js", "_routes.json"]);
889
+ const skip = new Set(["node_modules", ".git", "dist", "web"]);
890
+ const found: string[] = [];
891
+ const walk = (dir: string): void => {
892
+ for (const entry of readdirSync(dir)) {
893
+ const path = join(dir, entry);
894
+ const stats = statSync(path);
895
+ if (stats.isDirectory()) {
896
+ if (skip.has(entry)) continue;
897
+ if (functionnames.has(entry.toLowerCase())) found.push(path);
898
+ walk(path);
899
+ } else if (functionfiles.has(entry.toLowerCase())) found.push(path);
900
+ }
901
+ };
902
+ walk(root);
903
+ expect(found).toEqual([]);
904
+ });
905
+ });
906
+
907
+ describe("hardcoded relay url scan of the source tree", () => {
908
+ it("asserts no wss or ws endpoint literal and no url at all inside the four bridge modules", () => {
909
+ const root = process.cwd();
910
+ const bridgefiles = ["bridge.ts", "auth.ts"].map((name) => join(root, name));
911
+ for (const path of bridgefiles)
912
+ expect(
913
+ readFileSync(path, "utf8").replaceAll('indexOf("://")', "").includes("://"),
914
+ `${path} carries a url literal`,
915
+ ).toBe(false);
916
+ const codefiles = [
917
+ ...readdirSync(root)
918
+ .filter((file) => file.endsWith(".ts") && statSync(join(root, file)).isFile())
919
+ .map((file) => join(root, file)),
920
+ ...readdirSync(sitedirOf(root))
921
+ .filter((file) => file.endsWith(".html") || file.endsWith(".js") || file.endsWith(".json"))
922
+ .map((file) => join(sitedirOf(root), file)),
923
+ ];
924
+ for (const path of codefiles) {
925
+ const content = readFileSync(path, "utf8");
926
+ const scanned = content.replaceAll("wss://your-relay.example", "").replaceAll("ws://your-relay.example", "");
927
+ expect(/wss?:\/\/[^\s"')<]/.test(scanned), `${path} carries a hardcoded ws or wss endpoint`).toBe(false);
928
+ }
929
+ });
930
+ });
931
+
932
+ function sitedirOf(root: string): string {
933
+ return join(root, "web");
934
+ }
935
+
936
+ describe("chatbridge e2e against the testrelay on localhost", () => {
937
+ it("pairs the site with the extension, runs the chat and plan review round trip, rotates the token on reconnect and refuses the reused pairing code", async () => {
938
+ const relay = await starttestrelay({ idlewindow: 20000 });
939
+ try {
940
+ const open: socketopen = (url) =>
941
+ new Promise((resolve, reject) => {
942
+ const websocket = new WebSocket(url);
943
+ const socket: relaysocket = {
944
+ send: (frame) => websocket.send(frame),
945
+ close: (code) => websocket.close(code ?? 1000),
946
+ };
947
+ websocket.onopen = () => resolve(socket);
948
+ websocket.onmessage = (event) => socket.onmessage?.(String((event as MessageEvent).data));
949
+ websocket.onclose = (event) => socket.onclose?.((event as CloseEvent).code);
950
+ websocket.onerror = () => reject(new Error("The relay socket failed to open."));
951
+ });
952
+ const extstate = newrelayclient({
953
+ url: relay.url,
954
+ memberid: "ext",
955
+ role: "extension",
956
+ capabilities,
957
+ now: Date.now(),
958
+ });
959
+ const extensionconnected = await connectrelay({
960
+ state: extstate,
961
+ open,
962
+ pairingcode: relay.pairingcode,
963
+ now: () => Date.now(),
964
+ });
965
+ expect(extensionconnected.error).toBeUndefined();
966
+ expect(extensionconnected.state.state).toBe("connected");
967
+ expect(extensionconnected.state.sessionid).not.toBe("");
968
+ const extsocket = extensionconnected.socket!;
969
+ let extclient = extensionconnected.state;
970
+ extsocket.onmessage = (frame) => {
971
+ const received = receiveframe(extclient, frame, Date.now());
972
+ extclient = received.state;
973
+ };
974
+ const sitestate = newrelayclient({
975
+ url: relay.url,
976
+ memberid: "site-reviewer",
977
+ role: "site",
978
+ capabilities,
979
+ now: Date.now(),
980
+ });
981
+ const siteconnected = await connectrelay({
982
+ state: sitestate,
983
+ open,
984
+ pairingcode: relay.pairingcode,
985
+ now: () => Date.now(),
986
+ });
987
+ expect(siteconnected.error).toBeUndefined();
988
+ expect(siteconnected.state.sessionid).toBe(extensionconnected.state.sessionid);
989
+ const sitesocket = siteconnected.socket!;
990
+ let siteclient = siteconnected.state;
991
+ sitesocket.onmessage = (frame) => {
992
+ const received = receiveframe(siteclient, frame, Date.now());
993
+ siteclient = received.state;
994
+ };
995
+ const posted = postevent(
996
+ siteclient,
997
+ sitesocket,
998
+ chateventof("collect the pricing table of this page", "site reviewer"),
999
+ Date.now(),
1000
+ );
1001
+ expect(posted.sent).toBe(true);
1002
+ await new Promise((resolve) => setTimeout(resolve, 300));
1003
+ expect(extclient.streams.chat?.length).toBe(1);
1004
+ expect(extclient.streams.chat?.[0]?.payload.text).toBe("collect the pricing table of this page");
1005
+ const card: bridgereviewcard = {
1006
+ proposalid: "plan-e2e",
1007
+ title: "Collect the pricing table",
1008
+ steps: [
1009
+ { id: "s1", kind: "readtext", origin: "https://shop.example", sensitive: false },
1010
+ { id: "s2", kind: "submitticket", origin: "https://shop.example", sensitive: true },
1011
+ ],
1012
+ at: Date.now(),
1013
+ };
1014
+ const proposal = postevent(extclient, extsocket, planproposaleventof(card), Date.now());
1015
+ expect(proposal.sent).toBe(true);
1016
+ await new Promise((resolve) => setTimeout(resolve, 300));
1017
+ const siteevents = Object.values(siteclient.streams).flat();
1018
+ const cards = reviewcardsof(siteevents);
1019
+ expect(cards.length).toBe(1);
1020
+ expect(cards[0]?.proposalid).toBe("plan-e2e");
1021
+ expect(cards[0]?.steps.length).toBe(2);
1022
+ const decision = carddecision(cards[0]!, "changes", "site reviewer", Date.now(), "skip the sensitive step");
1023
+ const review = postevent(
1024
+ siteclient,
1025
+ sitesocket,
1026
+ { kind: "planreview", stream: "review", payload: decisionpayload(decision) },
1027
+ Date.now(),
1028
+ );
1029
+ expect(review.sent).toBe(true);
1030
+ await new Promise((resolve) => setTimeout(resolve, 300));
1031
+ const extevents = Object.values(extclient.streams).flat();
1032
+ const decisions = extevents.filter((event) => event.kind === "planreview");
1033
+ expect(decisions.length).toBe(1);
1034
+ const outcome = reviewgateoutcome(decision);
1035
+ expect(outcome.state).toBe("refused");
1036
+ expect(outcome.reason).toContain("no step executes");
1037
+ const rotatedtokenbefore = extclient.token;
1038
+ extsocket.close(1000);
1039
+ await new Promise((resolve) => setTimeout(resolve, 300));
1040
+ const reconnected = await connectrelay({
1041
+ state: { ...extclient, state: "reconnecting" },
1042
+ open,
1043
+ now: () => Date.now(),
1044
+ });
1045
+ expect(reconnected.error).toBeUndefined();
1046
+ expect(reconnected.state.state).toBe("connected");
1047
+ expect(reconnected.state.token).not.toBe(rotatedtokenbefore);
1048
+ expect(reconnected.state.rotations).toBeGreaterThan(extensionconnected.state.rotations);
1049
+ const replaystate = replaybridgequeue(
1050
+ {
1051
+ ...reconnected.state,
1052
+ queue: [
1053
+ {
1054
+ opid: "op-ext-replay-1",
1055
+ op: "eventpost",
1056
+ sessionid: reconnected.state.sessionid,
1057
+ body: eventpostbody({ event: chateventof("queued while the socket was down", "extension") }),
1058
+ queuedat: Date.now(),
1059
+ attempts: 0,
1060
+ },
1061
+ ],
1062
+ },
1063
+ reconnected.socket!,
1064
+ Date.now(),
1065
+ );
1066
+ expect(replaystate.replayed).toBe(1);
1067
+ await new Promise((resolve) => setTimeout(resolve, 300));
1068
+ const reused = await connectrelay({
1069
+ state: newrelayclient({ url: relay.url, memberid: "late-site", role: "site", capabilities, now: Date.now() }),
1070
+ open,
1071
+ pairingcode: relay.pairingcode,
1072
+ now: () => Date.now(),
1073
+ });
1074
+ expect(reused.error).toContain("pairing code");
1075
+ const session = relay.sessions()[0]!;
1076
+ expect(session.members.sort()).toEqual(["extension", "site"]);
1077
+ expect(session.events.filter((event) => event.kind === "chat").length).toBe(
1078
+ 2,
1079
+ ); /* the original round trip plus the replayed queued frame */
1080
+ expect(session.events.filter((event) => event.kind === "planproposal").length).toBe(1);
1081
+ expect(session.events.filter((event) => event.kind === "planreview").length).toBe(1);
1082
+ reconnected.socket?.close(1000);
1083
+ sitesocket.close(1000);
1084
+ } finally {
1085
+ await relay.close();
1086
+ }
1087
+ }, 20000);
1088
+ });