@reticlehq/server 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +130 -778
  3. package/dist/bridge/bridge.d.ts +23 -0
  4. package/dist/bridge/bridge.js +80 -2
  5. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  6. package/dist/bridge/bridge.test-harness.js +6 -4
  7. package/dist/capsule/causal-summary.d.ts +36 -1
  8. package/dist/capsule/causal-summary.js +33 -7
  9. package/dist/capsule/divergence.js +7 -1
  10. package/dist/cli/chromium-hint.d.ts +33 -5
  11. package/dist/cli/chromium-hint.js +82 -12
  12. package/dist/cli/cli-doctor.js +65 -34
  13. package/dist/cli/cli-flow-commands.js +8 -8
  14. package/dist/cli/cli-kill.d.ts +58 -0
  15. package/dist/cli/cli-kill.js +162 -0
  16. package/dist/cli/cli-parse.d.ts +16 -1
  17. package/dist/cli/cli-parse.js +32 -9
  18. package/dist/cli/cli-port.d.ts +32 -0
  19. package/dist/cli/cli-port.js +82 -35
  20. package/dist/cli/cli-verify.d.ts +21 -0
  21. package/dist/cli/cli-verify.js +47 -3
  22. package/dist/cli/config-discovery.d.ts +35 -0
  23. package/dist/cli/config-discovery.js +163 -0
  24. package/dist/cli/doctor-daemon-line.js +4 -1
  25. package/dist/cli/doctor-project-line.d.ts +37 -0
  26. package/dist/cli/doctor-project-line.js +35 -0
  27. package/dist/cli/doctor-rows.d.ts +51 -0
  28. package/dist/cli/doctor-rows.js +54 -0
  29. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  30. package/dist/cli/doctor-sessions-line.js +69 -0
  31. package/dist/cli/drive-attach.d.ts +99 -0
  32. package/dist/cli/drive-attach.js +203 -0
  33. package/dist/cli/drive-command.d.ts +13 -0
  34. package/dist/cli/drive-command.js +103 -0
  35. package/dist/cli/open-note.d.ts +20 -0
  36. package/dist/cli/open-note.js +37 -0
  37. package/dist/cli/port-holder.d.ts +10 -0
  38. package/dist/cli/port-holder.js +26 -2
  39. package/dist/cli/status-next-action.d.ts +37 -0
  40. package/dist/cli/status-next-action.js +61 -0
  41. package/dist/cli.js +119 -39
  42. package/dist/crawl/crawl-tools.js +12 -2
  43. package/dist/crawl/crawl.d.ts +13 -0
  44. package/dist/crawl/crawl.js +50 -2
  45. package/dist/events/contradictions.d.ts +46 -0
  46. package/dist/events/contradictions.js +35 -22
  47. package/dist/events/declared.d.ts +45 -0
  48. package/dist/events/declared.js +73 -0
  49. package/dist/events/echo-mismatch.d.ts +8 -2
  50. package/dist/events/echo-mismatch.js +33 -5
  51. package/dist/events/event-filters.d.ts +1 -1
  52. package/dist/events/event-filters.js +7 -4
  53. package/dist/events/predicate-eval.d.ts +17 -72
  54. package/dist/events/predicate-eval.js +117 -236
  55. package/dist/events/predicate-parse.js +31 -3
  56. package/dist/events/predicate-route.d.ts +32 -0
  57. package/dist/events/predicate-route.js +102 -0
  58. package/dist/events/predicate-schema.d.ts +99 -0
  59. package/dist/events/predicate-schema.js +309 -0
  60. package/dist/events/predicate.d.ts +8 -0
  61. package/dist/events/predicate.js +296 -22
  62. package/dist/flows/annotate-tools.js +3 -0
  63. package/dist/flows/annotate.js +18 -4
  64. package/dist/flows/coverage.d.ts +38 -5
  65. package/dist/flows/coverage.js +30 -4
  66. package/dist/flows/decision.d.ts +15 -0
  67. package/dist/flows/decision.js +1 -1
  68. package/dist/flows/flow-classify.js +1 -1
  69. package/dist/flows/flow-replay-run.js +8 -1
  70. package/dist/flows/flow-tools.js +21 -5
  71. package/dist/flows/heal-run.js +1 -1
  72. package/dist/flows/replay.js +3 -0
  73. package/dist/honesty/accepted-write.d.ts +3 -0
  74. package/dist/honesty/accepted-write.js +8 -3
  75. package/dist/honesty/already-true.js +15 -0
  76. package/dist/honesty/blind-spots.js +10 -2
  77. package/dist/honesty/honesty.d.ts +11 -0
  78. package/dist/honesty/honesty.js +1 -0
  79. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  80. package/dist/honesty/uncaptured-bodies.js +9 -2
  81. package/dist/honesty/unread-outcome.d.ts +8 -0
  82. package/dist/honesty/unread-outcome.js +36 -9
  83. package/dist/honesty/unsettled.d.ts +49 -0
  84. package/dist/honesty/unsettled.js +82 -0
  85. package/dist/honesty/verified.d.ts +36 -3
  86. package/dist/honesty/verified.js +54 -11
  87. package/dist/http-server.d.ts +19 -0
  88. package/dist/http-server.js +202 -4
  89. package/dist/impact/impact-recorder.d.ts +23 -0
  90. package/dist/impact/impact-recorder.js +76 -0
  91. package/dist/impact/impact-store.d.ts +47 -0
  92. package/dist/impact/impact-store.js +171 -0
  93. package/dist/index.d.ts +7 -0
  94. package/dist/index.js +47 -10
  95. package/dist/init/agent-rules.d.ts +36 -0
  96. package/dist/init/agent-rules.js +132 -22
  97. package/dist/init/astro-patch.d.ts +2 -1
  98. package/dist/init/astro-patch.js +38 -6
  99. package/dist/init/capabilities.d.ts +22 -0
  100. package/dist/init/capabilities.js +76 -0
  101. package/dist/init/confirm.d.ts +114 -0
  102. package/dist/init/confirm.js +165 -0
  103. package/dist/init/connect-steps.d.ts +2 -0
  104. package/dist/init/connect-steps.js +50 -0
  105. package/dist/init/cra.js +3 -2
  106. package/dist/init/csp-check.d.ts +38 -0
  107. package/dist/init/csp-check.js +92 -0
  108. package/dist/init/csp-doctor.d.ts +25 -0
  109. package/dist/init/csp-doctor.js +49 -0
  110. package/dist/init/cursor.js +6 -9
  111. package/dist/init/detect.d.ts +8 -0
  112. package/dist/init/detect.js +33 -3
  113. package/dist/init/mcp-clients.d.ts +1 -0
  114. package/dist/init/mcp-clients.js +51 -7
  115. package/dist/init/mcp.d.ts +15 -0
  116. package/dist/init/mcp.js +19 -0
  117. package/dist/init/non-js-project.js +29 -5
  118. package/dist/init/plan-framework.d.ts +10 -0
  119. package/dist/init/plan-framework.js +78 -16
  120. package/dist/init/plan.d.ts +47 -6
  121. package/dist/init/plan.js +228 -69
  122. package/dist/init/run.d.ts +24 -2
  123. package/dist/init/run.js +157 -45
  124. package/dist/init/slash-command.d.ts +1 -1
  125. package/dist/init/slash-command.js +54 -6
  126. package/dist/init/snippets.d.ts +45 -5
  127. package/dist/init/snippets.js +194 -42
  128. package/dist/init/vite-config.js +53 -5
  129. package/dist/input/network-mock-tools.d.ts +19 -0
  130. package/dist/input/network-mock-tools.js +37 -5
  131. package/dist/input/scroll-find.d.ts +6 -0
  132. package/dist/input/scroll-find.js +17 -2
  133. package/dist/input/scroll-tools.js +4 -0
  134. package/dist/journal/attach-journal.d.ts +9 -1
  135. package/dist/journal/attach-journal.js +13 -2
  136. package/dist/journal/journal-query.d.ts +13 -0
  137. package/dist/journal/journal-query.js +28 -3
  138. package/dist/journal/workspace-gitignore.d.ts +10 -0
  139. package/dist/journal/workspace-gitignore.js +59 -0
  140. package/dist/license/license.js +1 -1
  141. package/dist/mcp/mcp-proxy.d.ts +35 -30
  142. package/dist/mcp/mcp-proxy.js +306 -104
  143. package/dist/mcp/mcp.d.ts +10 -1
  144. package/dist/mcp/mcp.js +70 -34
  145. package/dist/mcp/proxy-handshake.d.ts +82 -1
  146. package/dist/mcp/proxy-handshake.js +109 -2
  147. package/dist/mcp/proxy-lifecycle.d.ts +11 -2
  148. package/dist/mcp/proxy-lifecycle.js +12 -3
  149. package/dist/mcp/proxy-log.d.ts +63 -0
  150. package/dist/mcp/proxy-log.js +132 -0
  151. package/dist/mcp/server-instructions.d.ts +17 -0
  152. package/dist/mcp/server-instructions.js +56 -0
  153. package/dist/mcp/shared-params.d.ts +27 -0
  154. package/dist/mcp/shared-params.js +36 -0
  155. package/dist/pool/browser-pool.d.ts +22 -2
  156. package/dist/pool/browser-pool.js +44 -4
  157. package/dist/project/learned-routes.d.ts +24 -0
  158. package/dist/project/learned-routes.js +63 -0
  159. package/dist/project/project-store.d.ts +6 -0
  160. package/dist/project/project-store.js +25 -1
  161. package/dist/project/reticle-dir.d.ts +2 -0
  162. package/dist/project/reticle-dir.js +1 -0
  163. package/dist/session/connection-memory.d.ts +59 -0
  164. package/dist/session/connection-memory.js +108 -0
  165. package/dist/session/dev-command.d.ts +26 -0
  166. package/dist/session/dev-command.js +89 -0
  167. package/dist/session/dev-server-probe.d.ts +68 -0
  168. package/dist/session/dev-server-probe.js +102 -7
  169. package/dist/session/last-act.d.ts +26 -0
  170. package/dist/session/last-act.js +35 -1
  171. package/dist/session/live-control-tools.js +42 -5
  172. package/dist/session/no-session-diagnosis.d.ts +67 -2
  173. package/dist/session/no-session-diagnosis.js +236 -27
  174. package/dist/session/no-session-next-action.d.ts +46 -0
  175. package/dist/session/no-session-next-action.js +85 -0
  176. package/dist/session/no-session-watch.d.ts +40 -1
  177. package/dist/session/no-session-watch.js +174 -8
  178. package/dist/session/pending-commands.d.ts +12 -0
  179. package/dist/session/pending-commands.js +13 -1
  180. package/dist/session/ref-provenance.d.ts +54 -0
  181. package/dist/session/ref-provenance.js +61 -0
  182. package/dist/session/session-lease.js +1 -1
  183. package/dist/session/session-manager.d.ts +4 -0
  184. package/dist/session/session-manager.js +35 -5
  185. package/dist/session/session.d.ts +10 -1
  186. package/dist/session/session.js +146 -5
  187. package/dist/telemetry/app-instrumented.d.ts +8 -0
  188. package/dist/telemetry/app-instrumented.js +10 -0
  189. package/dist/telemetry/automation-hint.d.ts +17 -0
  190. package/dist/telemetry/automation-hint.js +71 -0
  191. package/dist/telemetry/bug-found.d.ts +15 -17
  192. package/dist/telemetry/bug-found.js +52 -21
  193. package/dist/telemetry/cli-telemetry.js +7 -1
  194. package/dist/telemetry/daemon-telemetry.js +24 -0
  195. package/dist/telemetry/feedback.js +1 -1
  196. package/dist/telemetry/init-telemetry.js +9 -1
  197. package/dist/telemetry/install-source.d.ts +56 -0
  198. package/dist/telemetry/install-source.js +74 -0
  199. package/dist/telemetry/instrumentation-stall.d.ts +50 -0
  200. package/dist/telemetry/instrumentation-stall.js +90 -0
  201. package/dist/telemetry/run-telemetry.js +4 -1
  202. package/dist/telemetry/session-metrics.d.ts +8 -0
  203. package/dist/telemetry/session-metrics.js +15 -0
  204. package/dist/telemetry/telemetry.d.ts +7 -1
  205. package/dist/telemetry/telemetry.js +26 -2
  206. package/dist/telemetry/tool-refused.d.ts +19 -0
  207. package/dist/telemetry/tool-refused.js +55 -0
  208. package/dist/telemetry/verification-of.js +46 -7
  209. package/dist/temp-dir.d.ts +27 -0
  210. package/dist/temp-dir.js +46 -0
  211. package/dist/tools/act-tools.js +209 -28
  212. package/dist/tools/assert-grade.d.ts +1 -1
  213. package/dist/tools/assert-grade.js +27 -5
  214. package/dist/tools/assert-verdict.js +49 -7
  215. package/dist/tools/browser-tools.js +10 -0
  216. package/dist/tools/contract-tools.js +4 -0
  217. package/dist/tools/dynamic-tools.js +16 -7
  218. package/dist/tools/error-recovery.d.ts +10 -0
  219. package/dist/tools/error-recovery.js +73 -9
  220. package/dist/tools/feedback-invite.js +1 -1
  221. package/dist/tools/invoke-tool.js +159 -26
  222. package/dist/tools/lease-hint.d.ts +30 -8
  223. package/dist/tools/lease-hint.js +65 -14
  224. package/dist/tools/lease-tools.d.ts +40 -1
  225. package/dist/tools/lease-tools.js +181 -83
  226. package/dist/tools/navigate-result.js +1 -1
  227. package/dist/tools/observe-tools.js +10 -4
  228. package/dist/tools/observed-nothing.js +1 -1
  229. package/dist/tools/react-grace.d.ts +52 -0
  230. package/dist/tools/react-grace.js +90 -0
  231. package/dist/tools/read-tools.js +67 -5
  232. package/dist/tools/resolve-target.d.ts +33 -0
  233. package/dist/tools/resolve-target.js +53 -0
  234. package/dist/tools/settle-in-flight.d.ts +94 -0
  235. package/dist/tools/settle-in-flight.js +146 -0
  236. package/dist/tools/snapshot-delta.d.ts +10 -0
  237. package/dist/tools/snapshot-delta.js +194 -9
  238. package/dist/tools/tool-kit.d.ts +8 -0
  239. package/dist/tools/tool-surface.d.ts +60 -0
  240. package/dist/tools/tool-surface.js +86 -1
  241. package/dist/tools/tools.js +61 -8
  242. package/dist/tools/unadvertised-help.js +9 -6
  243. package/docs/README.md +55 -11
  244. package/docs/actions.mdx +120 -0
  245. package/docs/agent-cheatsheet.md +95 -29
  246. package/docs/architecture.md +52 -35
  247. package/docs/benchmarks.md +127 -75
  248. package/docs/best-practices.mdx +216 -0
  249. package/docs/capabilities.mdx +534 -0
  250. package/docs/cli/affected.mdx +53 -0
  251. package/docs/cli/capsules.mdx +70 -0
  252. package/docs/cli/cloud.mdx +153 -0
  253. package/docs/cli/doctor.mdx +109 -0
  254. package/docs/cli/drive.mdx +74 -0
  255. package/docs/cli/feedback.mdx +67 -0
  256. package/docs/cli/gate.mdx +64 -0
  257. package/docs/cli/help.mdx +94 -0
  258. package/docs/cli/hunt.mdx +73 -0
  259. package/docs/cli/identify.mdx +76 -0
  260. package/docs/cli/init.mdx +121 -0
  261. package/docs/cli/kill.mdx +91 -0
  262. package/docs/cli/license.mdx +48 -0
  263. package/docs/cli/mcp.mdx +61 -0
  264. package/docs/cli/open.mdx +74 -0
  265. package/docs/cli/restart.mdx +60 -0
  266. package/docs/cli/rollback.mdx +35 -0
  267. package/docs/cli/serve.mdx +69 -0
  268. package/docs/cli/status.mdx +88 -0
  269. package/docs/cli/stop.mdx +64 -0
  270. package/docs/cli/telemetry.mdx +57 -0
  271. package/docs/cli/update.mdx +52 -0
  272. package/docs/cli/verify.mdx +76 -0
  273. package/docs/cli/version.mdx +39 -0
  274. package/docs/cli/watch.mdx +62 -0
  275. package/docs/cli.mdx +241 -0
  276. package/docs/debugging.md +16 -10
  277. package/docs/deploy-checks.md +45 -12
  278. package/docs/desktop.mdx +384 -0
  279. package/docs/docs.json +364 -0
  280. package/docs/enterprise.md +22 -16
  281. package/docs/faq.mdx +102 -0
  282. package/docs/fixtures.md +13 -7
  283. package/docs/flows.md +29 -23
  284. package/docs/for-agents.md +71 -0
  285. package/docs/frameworks.mdx +238 -0
  286. package/docs/gate-plan.md +48 -42
  287. package/docs/gates.md +28 -22
  288. package/docs/getting-started.md +129 -77
  289. package/docs/human-control.md +24 -18
  290. package/docs/index.mdx +143 -0
  291. package/docs/install-agentic.mdx +224 -0
  292. package/docs/install-manual.mdx +196 -0
  293. package/docs/instrumentation.mdx +238 -0
  294. package/docs/integration-patterns.md +41 -35
  295. package/docs/local-registry.md +19 -13
  296. package/docs/multi-agent-testing.md +19 -13
  297. package/docs/packages/babel-plugin.mdx +79 -0
  298. package/docs/packages/browser.mdx +111 -0
  299. package/docs/packages/core.mdx +116 -0
  300. package/docs/packages/electron.mdx +85 -0
  301. package/docs/packages/eslint-plugin.mdx +86 -0
  302. package/docs/packages/next.mdx +89 -0
  303. package/docs/packages/react.mdx +91 -0
  304. package/docs/packages/server.mdx +98 -0
  305. package/docs/packages/tauri.mdx +63 -0
  306. package/docs/packages/test.mdx +119 -0
  307. package/docs/packages/vite-plugin.mdx +94 -0
  308. package/docs/packages.mdx +218 -0
  309. package/docs/platform-integration.md +35 -26
  310. package/docs/predicates.mdx +248 -0
  311. package/docs/quickstart.mdx +257 -0
  312. package/docs/recipes.mdx +282 -0
  313. package/docs/skill-file.mdx +107 -0
  314. package/docs/state-management.mdx +199 -0
  315. package/docs/style.css +14 -0
  316. package/docs/system-map.md +50 -33
  317. package/docs/telemetry-contract.md +106 -52
  318. package/docs/telemetry-events.mdx +475 -0
  319. package/docs/telemetry.md +63 -55
  320. package/docs/testing.md +44 -6
  321. package/docs/token-efficiency.md +37 -27
  322. package/docs/tools-act-and-wait.mdx +173 -0
  323. package/docs/tools-act-sequence.mdx +137 -0
  324. package/docs/tools-act.mdx +95 -0
  325. package/docs/tools-assert.mdx +129 -0
  326. package/docs/tools-console.mdx +63 -0
  327. package/docs/tools-inspect.mdx +88 -0
  328. package/docs/tools-navigate.mdx +99 -0
  329. package/docs/tools-network.mdx +119 -0
  330. package/docs/tools-observe.mdx +145 -0
  331. package/docs/tools-overview.mdx +160 -0
  332. package/docs/tools-query.mdx +126 -0
  333. package/docs/tools-session-and-feedback.mdx +112 -0
  334. package/docs/tools-sessions.mdx +94 -0
  335. package/docs/tools-snapshot.mdx +129 -0
  336. package/docs/tools-state.mdx +104 -0
  337. package/docs/tools-tools-and-run.mdx +156 -0
  338. package/docs/tools-wait-for.mdx +85 -0
  339. package/docs/troubleshooting.mdx +263 -0
  340. package/docs/usage.md +209 -165
  341. package/docs/vs-chrome-devtools-mcp.mdx +123 -0
  342. package/docs/vs-playwright-mcp.mdx +125 -0
  343. package/docs/vs-screenshots.mdx +137 -0
  344. package/docs/what-is-reticle.mdx +63 -0
  345. package/docs/why-reticle.mdx +109 -0
  346. package/package.json +5 -5
  347. package/docs/desktop-apps.md +0 -256
  348. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  349. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  350. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  351. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  352. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  353. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  354. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  355. package/docs/matrix/MATRIX.md +0 -26
  356. package/docs/matrix/README.md +0 -59
  357. package/docs/mint.json +0 -61
@@ -1,197 +1,10 @@
1
- import { ElementQuerySchema, ElementState, EventType, PredicateKind, StreamDirection, isDevToolingUrl, } from '@reticlehq/core';
2
- import { z } from 'zod';
1
+ import { EventType, PredicateKind, StreamDirection, isDevToolingUrl, } from '@reticlehq/core';
3
2
  import { describeObserved } from './observed-in-window.js';
4
- /**
5
- * Spellings an agent plausibly reaches for, mapped to the real field.
6
- *
7
- * `route` spells its field `pathname` while `state`, in the same union, spells its `path`. Every one
8
- * of these was silently DROPPED before, and because these kinds have all-optional fields, dropping
9
- * the only key supplied left a predicate that asserts nothing and passes on anything.
10
- */
11
- const PREDICATE_ALIASES = {
12
- // `text`/`value` on a `text` predicate: the kind is called "text", so `text:` is the first thing
13
- // anyone writes for it, and `value` follows from the element query's own `value` field. Both were
14
- // hard rejections, and a rejected predicate produces NO verdict at all — the drive ends with
15
- // nothing rather than with a failure, which is the worst outcome of the three.
16
- [PredicateKind.TEXT]: { text: 'contains', value: 'contains' },
17
- // `urlContains`/`url` reported from the field: an agent that had just written
18
- // `net { urlContains }` applied the same word to `route`, which spells it `contains`, and got
19
- // `unrecognized_keys` with no list of what would have worked. The parallel it assumed is a fair
20
- // one — route's `contains` matches the WHOLE route (path + query + fragment), so "the URL
21
- // contains this" is precisely what it does — and asserting a redirect after login is the single
22
- // most common thing an agent reaches for here.
23
- [PredicateKind.ROUTE]: { path: 'pathname', urlContains: 'contains', url: 'contains' },
24
- [PredicateKind.NET]: { url: 'urlContains' },
25
- [PredicateKind.SIGNAL]: { data: 'dataMatches' },
26
- };
27
- /**
28
- * Element-query fields an agent writes FLAT on an `element` predicate instead of nested under
29
- * `query`. `reticle_query` takes exactly these at the top level, so an agent that has just located
30
- * something writes the same words again when it asserts on it — and got `query: Required` plus an
31
- * `unknown field` list for its trouble. Lifting them is unambiguous: they have no other meaning on
32
- * this kind.
33
- */
34
- const ELEMENT_QUERY_FIELDS = [
35
- 'by',
36
- 'value',
37
- 'role',
38
- 'name',
39
- 'text',
40
- 'label',
41
- 'placeholder',
42
- 'testid',
43
- 'alt',
44
- 'component',
45
- ];
46
- /** Rename known aliases before parse; an explicit canonical key always wins. */
47
- function applyPredicateAliases(input) {
48
- if (typeof input !== 'object' || null === input || Array.isArray(input))
49
- return input;
50
- const obj = input;
51
- const kind = 'string' === typeof obj['kind'] ? obj['kind'] : '';
52
- const aliases = PREDICATE_ALIASES[kind];
53
- let out = obj;
54
- if (aliases !== undefined) {
55
- out = { ...obj };
56
- for (const [from, to] of Object.entries(aliases)) {
57
- if (out[from] === undefined)
58
- continue;
59
- if (out[to] === undefined)
60
- out[to] = out[from];
61
- delete out[from];
62
- }
63
- }
64
- return PredicateKind.ELEMENT === kind ? liftElementQuery(out) : out;
65
- }
66
- /**
67
- * Fold flat query fields into `query`. An explicit `query` wins outright — a caller that supplied
68
- * both told us which one it meant, and merging the two would invent a locator neither side wrote.
69
- */
70
- function liftElementQuery(obj) {
71
- const loose = ELEMENT_QUERY_FIELDS.filter((field) => obj[field] !== undefined);
72
- if (0 === loose.length)
73
- return obj;
74
- const out = { ...obj };
75
- const query = {};
76
- for (const field of loose) {
77
- query[field] = out[field];
78
- delete out[field];
79
- }
80
- if (out['query'] === undefined)
81
- out['query'] = query;
82
- return out;
83
- }
84
- /**
85
- * Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
86
- * a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
87
- *
88
- * Built on demand rather than at module scope: the `allOf`/`anyOf`/`not` branches reference
89
- * `PredicateSchema` itself, which does not exist yet while this module is initialising. Calling it
90
- * after init is what makes the union introspectable — see `predicateFieldsFor`.
91
- */
92
- function predicateUnion() {
93
- return z.discriminatedUnion('kind', [
94
- z
95
- .object({
96
- kind: z.literal(PredicateKind.ELEMENT),
97
- query: ElementQuerySchema,
98
- state: z.nativeEnum(ElementState).optional(),
99
- absent: z.boolean().optional(),
100
- })
101
- .strict(),
102
- z
103
- .object({
104
- kind: z.literal(PredicateKind.TEXT),
105
- contains: z.string(),
106
- visible: z.boolean().optional(),
107
- absent: z.boolean().optional(),
108
- })
109
- .strict(),
110
- z
111
- .object({
112
- kind: z.literal(PredicateKind.NET),
113
- method: z.string().optional(),
114
- urlContains: z.string().optional(),
115
- status: z.number().optional(),
116
- ok: z.boolean().optional(),
117
- since: z.number().optional(),
118
- count: z.number().int().nonnegative().optional(),
119
- })
120
- .strict(),
121
- z
122
- .object({
123
- kind: z.literal(PredicateKind.ROUTE),
124
- pathname: z.string().optional(),
125
- contains: z.string().optional(),
126
- since: z.number().optional(),
127
- })
128
- .strict(),
129
- z
130
- .object({
131
- kind: z.literal(PredicateKind.CONSOLE),
132
- level: z.string().optional(),
133
- absent: z.boolean().optional(),
134
- since: z.number().optional(),
135
- })
136
- .strict(),
137
- z
138
- .object({
139
- kind: z.literal(PredicateKind.ANIMATION),
140
- name: z.string().optional(),
141
- target: z.string().optional(),
142
- completed: z.boolean().optional(),
143
- since: z.number().optional(),
144
- })
145
- .strict(),
146
- z
147
- .object({
148
- kind: z.literal(PredicateKind.SIGNAL),
149
- name: z.string().optional(),
150
- dataMatches: z.record(z.unknown()).optional(),
151
- since: z.number().optional(),
152
- })
153
- .strict(),
154
- z
155
- .object({
156
- kind: z.literal(PredicateKind.STATE),
157
- store: z.string().optional(),
158
- path: z.string(),
159
- equals: z.unknown().optional(),
160
- })
161
- .strict(),
162
- z
163
- .object({
164
- kind: z.literal(PredicateKind.SETTLED),
165
- quietMs: z.number().positive().optional(),
166
- })
167
- .strict(),
168
- z
169
- .object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
170
- .strict(),
171
- z
172
- .object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
173
- .strict(),
174
- z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
175
- ]);
176
- }
177
- export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, predicateUnion()));
178
- /**
179
- * The fields a given predicate kind accepts, read off the schema itself.
180
- *
181
- * Derived rather than listed so the two can never disagree: a rejection message that names a stale
182
- * field set is worse than one that names none, because the agent trusts it and retries into the same
183
- * wall. Empty for a kind that is not in the union.
184
- */
185
- export function predicateFieldsFor(kind) {
186
- for (const option of predicateUnion().options) {
187
- const literal = option.shape['kind'];
188
- if (literal instanceof z.ZodLiteral && literal.value === kind) {
189
- return Object.keys(option.shape).filter((field) => 'kind' !== field);
190
- }
191
- }
192
- return [];
193
- }
194
- function str(value) {
3
+ // The predicate SHAPE — the discriminated union, its aliases and its zod schema — lives in
4
+ // predicate-schema.ts. Re-exported here so every existing importer of this module is unaffected:
5
+ // the two halves are one public surface, split only because the file outgrew the size backstop.
6
+ export * from './predicate-schema.js';
7
+ export function str(value) {
195
8
  return 'string' === typeof value ? value : undefined;
196
9
  }
197
10
  function num(value) {
@@ -256,7 +69,37 @@ export function matchValue(got, want) {
256
69
  }
257
70
  return true;
258
71
  }
259
- return got === want;
72
+ return structurallyEqual(got, want);
73
+ }
74
+ /**
75
+ * Value equality for the leaf comparison, because `===` could never match a literal.
76
+ *
77
+ * The expected side of a predicate is parsed out of the agent's JSON, so it is a fresh object every
78
+ * time — reference equality made `equals: ["a", "b"]` false against a store holding exactly
79
+ * `["a", "b"]`, and there was no value of the app's state that could have made it true. The mirror of
80
+ * a false green: an assertion nobody could satisfy, on the commonest thing there is to assert about.
81
+ *
82
+ * Deliberately strict about shape. An array is not an object, an extra key is a difference, and order
83
+ * is part of a list's value — `equals` means equals; `dataMatches` is the field-by-field one.
84
+ */
85
+ function structurallyEqual(got, want) {
86
+ if (got === want)
87
+ return true;
88
+ if (null === got || null === want)
89
+ return false;
90
+ if ('object' !== typeof got || 'object' !== typeof want)
91
+ return false;
92
+ if (Array.isArray(got) !== Array.isArray(want))
93
+ return false;
94
+ if (Array.isArray(got) && Array.isArray(want)) {
95
+ return got.length === want.length && got.every((v, i) => structurallyEqual(v, want[i]));
96
+ }
97
+ const a = got;
98
+ const b = want;
99
+ const keys = Object.keys(a);
100
+ if (keys.length !== Object.keys(b).length)
101
+ return false;
102
+ return keys.every((k) => k in b && structurallyEqual(a[k], b[k]));
260
103
  }
261
104
  /** Shallow JSON pattern match: each key in `pattern` must match (see matchValue). */
262
105
  function dataMatches(actual, pattern) {
@@ -297,8 +140,46 @@ function describeCall(e) {
297
140
  const status = num(e.data['status']);
298
141
  return status === undefined ? head : `${head} → ${String(status)}`;
299
142
  }
143
+ /**
144
+ * How much of a response body a failure may quote. Enough to see the value that differed on the
145
+ * bodies this field is used against (a JSON answer), and not a whole payload in every verdict.
146
+ */
147
+ const MAX_BODY_IN_FAILURE = 200;
148
+ /** Enough of a body to see what differed, without paying for a whole payload in the verdict. */
149
+ function clipBody(body) {
150
+ return body.length <= MAX_BODY_IN_FAILURE ? body : `${body.slice(0, MAX_BODY_IN_FAILURE)}…`;
151
+ }
152
+ /**
153
+ * The filter as APPLIED, every field of it.
154
+ *
155
+ * The count failure used to print `{method, urlContains, status}` while `ok` and `bodyContains` were
156
+ * applied too, so the caller was shown a predicate it had not written and told nothing matched it.
157
+ * A printed filter that is narrower than the real one is worse than none: it is believed.
158
+ */
159
+ function describeNetFilter(p) {
160
+ const { kind: _kind, count: _count, since: _since, ...filter } = p;
161
+ return JSON.stringify(filter);
162
+ }
300
163
  export function evalNet(events, p) {
301
164
  const since = p.since ?? 0;
165
+ /**
166
+ * Did any call match everything EXCEPT the body assertion, while carrying no recorded body?
167
+ *
168
+ * Bodies are opt-in, so without them a body predicate can never hold — and "no call matched" would
169
+ * send the caller to check the url and the method, which are both fine, instead of to the one
170
+ * setting that makes the assertion possible. Tracked while filtering rather than recomputed after,
171
+ * because it is the same pass over the same events.
172
+ */
173
+ let matchedButUnrecorded = false;
174
+ /**
175
+ * The response body of a call that matched everything EXCEPT the body assertion, and HAD one.
176
+ *
177
+ * Reported by the first field user of `bodyContains`: a body mismatch was formatted as
178
+ * "expected 1 network call(s) matching {method, urlContains, status}, saw 0" — the body assertion
179
+ * was applied but not printed — so the verdict read "the request never fired" and sent them hunting
180
+ * a UI wiring bug that did not exist, when the defect was the value the server answered with.
181
+ */
182
+ let bodyMismatch;
302
183
  const matches = events.filter((e) => {
303
184
  if (e.type !== EventType.NET_REQUEST || e.t < since)
304
185
  return false;
@@ -313,8 +194,43 @@ export function evalNet(events, p) {
313
194
  return false;
314
195
  if (p.ok !== undefined && callSucceeded(d) !== p.ok)
315
196
  return false;
197
+ if (p.bodyContains !== undefined) {
198
+ // The RESPONSE body only, and this is the whole point of the field. Searching the request too
199
+ // would let `bodyContains: "1187.01"` pass on the very defect it exists to catch: the app SENT
200
+ // that number, so it is in the request whatever the server then did with it. The server's answer
201
+ // is the one channel a UI cannot fake.
202
+ const response = str(d['responseBody']);
203
+ if (response === undefined) {
204
+ matchedButUnrecorded = true;
205
+ return false;
206
+ }
207
+ if (!response.includes(p.bodyContains)) {
208
+ bodyMismatch ??= response;
209
+ return false;
210
+ }
211
+ }
316
212
  return true;
317
213
  });
214
+ if (matchedButUnrecorded && 0 === matches.length) {
215
+ return {
216
+ pass: false,
217
+ failureReason: `a call matched but its body was not recorded, so \`bodyContains\` could not be checked — enable it where the app calls connect(): reticle({ captureNetworkBodies: true })`,
218
+ observed: 'a matching call with no recorded body',
219
+ expected: `a body containing ${JSON.stringify(p.bodyContains)}`,
220
+ assertion: 'net.bodyContains',
221
+ };
222
+ }
223
+ if (bodyMismatch !== undefined && 0 === matches.length) {
224
+ // The call is there and its body is there; only the VALUE differs. Counting it as zero matches
225
+ // points at the wiring, which is the one place the defect is not.
226
+ return {
227
+ pass: false,
228
+ failureReason: `a call matching ${describeNetFilter(p)} was made and answered ${JSON.stringify(clipBody(bodyMismatch))}, which does not contain ${JSON.stringify(p.bodyContains)} — the request fired, the response value is what differed`,
229
+ observed: `response body ${JSON.stringify(clipBody(bodyMismatch))}`,
230
+ expected: `a response body containing ${JSON.stringify(p.bodyContains)}`,
231
+ assertion: 'net.bodyContains',
232
+ };
233
+ }
318
234
  // `count` (exact) turns presence into a cardinality assertion — catches the double-submit /
319
235
  // useEffect-double-fire / retry-storm regression class, where the request DID fire (presence passes)
320
236
  // but fired the WRONG number of times. Without `count`, the matcher is presence-only (≥1).
@@ -323,9 +239,13 @@ export function evalNet(events, p) {
323
239
  ? { pass: true, evidence: { matched: matches.length } }
324
240
  : {
325
241
  pass: false,
326
- failureReason: `expected ${String(p.count)} network call(s) matching ${JSON.stringify({ method: p.method, urlContains: p.urlContains, status: p.status })}, saw ${String(matches.length)}`,
242
+ // Monotonic: more matches than asked for can never become exactly the number asked for.
243
+ // This is the double-submit — two writes 59ms apart — so the finding that matters most was
244
+ // also the one that took the caller's whole budget to report.
245
+ ...(matches.length > p.count ? { decided: true } : {}),
246
+ failureReason: `expected ${String(p.count)} network call(s) matching ${describeNetFilter(p)}, saw ${String(matches.length)}`,
327
247
  observed: `${String(matches.length)} matching network call(s)`,
328
- expected: `exactly ${String(p.count)} matching ${JSON.stringify({ method: p.method, urlContains: p.urlContains, status: p.status })}`,
248
+ expected: `exactly ${String(p.count)} matching ${describeNetFilter(p)}`,
329
249
  assertion: 'net.count',
330
250
  };
331
251
  }
@@ -342,45 +262,6 @@ export function evalNet(events, p) {
342
262
  assertion: 'net.present',
343
263
  };
344
264
  }
345
- export function evalRoute(events, p) {
346
- const routes = events.filter((e) => e.type === EventType.ROUTE_CHANGE);
347
- const last = routes.at(-1);
348
- if (last === undefined) {
349
- return {
350
- pass: false,
351
- failureReason: 'no route change observed',
352
- observed: 'no route change in the window',
353
- expected: `a route change to ${p.pathname ?? p.contains ?? 'any route'}`,
354
- assertion: 'route.changed',
355
- };
356
- }
357
- const pathname = str(last.data['pathname']) ?? str(last.data['to']) ?? '';
358
- if (p.pathname !== undefined && pathname !== p.pathname) {
359
- return {
360
- pass: false,
361
- failureReason: `route is '${pathname}', expected '${p.pathname}'`,
362
- observed: `route '${pathname}'`,
363
- expected: `route '${p.pathname}'`,
364
- assertion: 'route.pathname',
365
- };
366
- }
367
- // `contains` matches the WHOLE route — path + query + fragment — while `pathname` above stays an
368
- // exact path match. A hash router keeps the entire route in the fragment, so matching pathname
369
- // alone made `contains` unsatisfiable for every HashRouter app; that is the standard router for a
370
- // packaged Electron/Tauri renderer, where an absolute pushState would rewrite the URL to a
371
- // nonexistent file.
372
- const fullRoute = `${pathname}${str(last.data['search']) ?? ''}${str(last.data['hash']) ?? ''}`;
373
- if (p.contains !== undefined && !fullRoute.includes(p.contains)) {
374
- return {
375
- pass: false,
376
- failureReason: `route '${fullRoute}' does not contain '${p.contains}'`,
377
- observed: `route '${fullRoute}'`,
378
- expected: `a route containing '${p.contains}'`,
379
- assertion: 'route.contains',
380
- };
381
- }
382
- return { pass: true, evidence: last.data };
383
- }
384
265
  /** The only console levels Reticle instruments (console.info/debug/trace are NOT patched). */
385
266
  const CONSOLE_LEVEL_TYPE = {
386
267
  log: EventType.CONSOLE_LOG,
@@ -486,7 +367,7 @@ export function evalSignal(events, p) {
486
367
  ? `signal '${p.name ?? '(any)'}' fired ${String(sameName.length)}x, payload: ${JSON.stringify(sameName[0])}`
487
368
  : // Name what DID fire: a typo'd signal name and a genuinely dead action produce the same
488
369
  // sentence otherwise, and the agent cannot tell them apart. See observed-in-window.ts.
489
- `signal '${p.name ?? '(any)'}' never fired — ${describeObserved('signals', events.filter((e) => e.type === EventType.SIGNAL).map((e) => str(e.data['name']) ?? ''))}`,
370
+ `signal '${p.name ?? '(any)'}' never fired; ${describeObserved('signals', events.filter((e) => e.type === EventType.SIGNAL).map((e) => str(e.data['name']) ?? ''))}`,
490
371
  expected: p.dataMatches === undefined
491
372
  ? `signal '${p.name ?? '(any)'}' to fire`
492
373
  : `signal '${p.name ?? '(any)'}' with payload matching ${JSON.stringify(p.dataMatches)}`,
@@ -15,8 +15,36 @@
15
15
  import { z } from 'zod';
16
16
  import { PredicateKind } from '@reticlehq/core';
17
17
  import { PredicateSchema, predicateFieldsFor } from './predicate-eval.js';
18
- /** A valid call, short enough to sit inside an error without becoming the error. */
19
- const EXAMPLE = '{ kind: "signal", name: "todos:loaded" }';
18
+ /**
19
+ * One valid call PER KIND, because an example of another kind answers a question nobody asked.
20
+ *
21
+ * This was a single `signal` example shown for every rejection. Watched on a real drive: an agent got
22
+ * the `element` shape wrong, was shown a `signal` example, got `element` wrong again, and was shown
23
+ * the same `signal` example. A rejected predicate produces no verdict at all, so each of those was a
24
+ * round trip that ended with nothing — and the one field an agent most needs (`element`'s nested
25
+ * `query`) is exactly what a flat example cannot convey.
26
+ *
27
+ * Short by design: an example that grows past a line stops being read.
28
+ */
29
+ const EXAMPLES = {
30
+ [PredicateKind.ELEMENT]: '{ kind: "element", query: { role: "button", name: "Save" } }',
31
+ [PredicateKind.TEXT]: '{ kind: "text", contains: "Saved" }',
32
+ [PredicateKind.NET]: '{ kind: "net", method: "POST", urlContains: "/api/save", status: 200 }',
33
+ [PredicateKind.ROUTE]: '{ kind: "route", pathname: "/dashboard" }',
34
+ [PredicateKind.CONSOLE]: '{ kind: "console", level: "error", absent: true }',
35
+ [PredicateKind.ANIMATION]: '{ kind: "animation", name: "slide-in", completed: true }',
36
+ [PredicateKind.SIGNAL]: '{ kind: "signal", name: "todos:loaded" }',
37
+ [PredicateKind.STATE]: '{ kind: "state", path: "cart.total", equals: 0 }',
38
+ [PredicateKind.SETTLED]: '{ kind: "settled" }',
39
+ [PredicateKind.ALL_OF]: '{ kind: "allOf", predicates: [{ kind: "text", contains: "Saved" }, { kind: "console", level: "error", absent: true }] }',
40
+ [PredicateKind.ANY_OF]: '{ kind: "anyOf", predicates: [{ kind: "text", contains: "Saved" }, { kind: "text", contains: "Updated" }] }',
41
+ [PredicateKind.NOT]: '{ kind: "not", predicate: { kind: "text", contains: "Error" } }',
42
+ };
43
+ /** The fallback, for when the KIND itself is the mistake and there is no shape to demonstrate. */
44
+ const GENERIC_EXAMPLE = '{ kind: "text", contains: "Saved" }';
45
+ function exampleFor(kind) {
46
+ return EXAMPLES[kind] ?? GENERIC_EXAMPLE;
47
+ }
20
48
  /** `path: ["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
21
49
  function pathOf(issue) {
22
50
  return 0 === issue.path.length ? 'the predicate' : issue.path.map(String).join('.');
@@ -51,7 +79,7 @@ export function parsePredicate(input) {
51
79
  const issues = parsed.error.issues.slice(0, 3).map(describeIssue).join('; ');
52
80
  throw new Error(`that predicate did not parse (kind "${kind}"): ${issues}. Nothing ran — the predicate was ` +
53
81
  `not evaluated, so no verdict was produced. ${accepted(kind)} ` +
54
- `A valid call looks like: ${EXAMPLE}`);
82
+ `A valid ${kind} predicate looks like: ${exampleFor(kind)}`);
55
83
  }
56
84
  /**
57
85
  * What WOULD have worked, in the same breath as what did not.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The `route` oracle. Split out of predicate-eval.ts, which was at the file-size backstop.
3
+ *
4
+ * Route is the one predicate with TWO sources of truth — a navigation inside the window and where the
5
+ * app is right now — and keeping that reconciliation in one place is what makes it readable.
6
+ */
7
+ import { PredicateKind, type ReticleEvent } from '@reticlehq/core';
8
+ import { type EvalResult, type Predicate } from './predicate-eval.js';
9
+ /**
10
+ * Which fact answered a `route` predicate: a navigation inside the window, or where the app is now.
11
+ *
12
+ * Reported on every route result because the two are different claims. "It changed to /login" proves
13
+ * a redirect; "it was already /login" does not, and an agent asserting a post-login redirect has to
14
+ * be able to tell them apart. Silently conflating them would trade one wrong verdict for another.
15
+ */
16
+ export declare const RouteDecidedBy: {
17
+ readonly CHANGE: "route-change";
18
+ readonly CURRENT: "current-route";
19
+ };
20
+ export declare function evalRoute(events: ReticleEvent[], p: Extract<Predicate, {
21
+ kind: typeof PredicateKind.ROUTE;
22
+ }>,
23
+ /**
24
+ * Where the app is RIGHT NOW, if the caller can say.
25
+ *
26
+ * Without it a route predicate could only ever be answered by a navigation inside the window, so
27
+ * "did the session survive a reload?" — which by definition produces no route change — and "we
28
+ * landed on /login" after a completed navigate were both a guaranteed false red, contradicted by
29
+ * `reticle_snapshot({ mode: 'status' }).route` one call later. Optional so a caller with no session
30
+ * (a replayed window) keeps the old answer instead of inventing a route.
31
+ */
32
+ currentUrl?: string): EvalResult;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The `route` oracle. Split out of predicate-eval.ts, which was at the file-size backstop.
3
+ *
4
+ * Route is the one predicate with TWO sources of truth — a navigation inside the window and where the
5
+ * app is right now — and keeping that reconciliation in one place is what makes it readable.
6
+ */
7
+ import { EventType, PredicateKind } from '@reticlehq/core';
8
+ import { str } from './predicate-eval.js';
9
+ /**
10
+ * Which fact answered a `route` predicate: a navigation inside the window, or where the app is now.
11
+ *
12
+ * Reported on every route result because the two are different claims. "It changed to /login" proves
13
+ * a redirect; "it was already /login" does not, and an agent asserting a post-login redirect has to
14
+ * be able to tell them apart. Silently conflating them would trade one wrong verdict for another.
15
+ */
16
+ export const RouteDecidedBy = {
17
+ CHANGE: 'route-change',
18
+ CURRENT: 'current-route',
19
+ };
20
+ /**
21
+ * The session's live URL, split the way a route-change event already carries it.
22
+ *
23
+ * Absolute (that is what the session tracks), but a relative value is accepted rather than thrown
24
+ * away — an unreadable URL is not worth losing the fallback over.
25
+ */
26
+ function readCurrentRoute(url) {
27
+ const parsed = URL.canParse(url) ? new URL(url) : undefined;
28
+ const pathname = parsed?.pathname ?? url;
29
+ const search = parsed?.search ?? '';
30
+ const hash = parsed?.hash ?? '';
31
+ return {
32
+ pathname,
33
+ full: `${pathname}${search}${hash}`,
34
+ decidedBy: RouteDecidedBy.CURRENT,
35
+ data: { pathname, search, hash, url, decidedBy: RouteDecidedBy.CURRENT },
36
+ };
37
+ }
38
+ /** Name the source in the prose too — the structured `decidedBy` is for the agent, this is for a log. */
39
+ function describeRouteSource(reading) {
40
+ return reading.decidedBy === RouteDecidedBy.CHANGE
41
+ ? 'route changed to'
42
+ : 'current route (no route change in the window)';
43
+ }
44
+ export function evalRoute(events, p,
45
+ /**
46
+ * Where the app is RIGHT NOW, if the caller can say.
47
+ *
48
+ * Without it a route predicate could only ever be answered by a navigation inside the window, so
49
+ * "did the session survive a reload?" — which by definition produces no route change — and "we
50
+ * landed on /login" after a completed navigate were both a guaranteed false red, contradicted by
51
+ * `reticle_snapshot({ mode: 'status' }).route` one call later. Optional so a caller with no session
52
+ * (a replayed window) keeps the old answer instead of inventing a route.
53
+ */
54
+ currentUrl) {
55
+ const routes = events.filter((e) => e.type === EventType.ROUTE_CHANGE);
56
+ const last = routes.at(-1);
57
+ const reading = last !== undefined
58
+ ? {
59
+ pathname: str(last.data['pathname']) ?? str(last.data['to']) ?? '',
60
+ full: `${str(last.data['pathname']) ?? str(last.data['to']) ?? ''}${str(last.data['search']) ?? ''}${str(last.data['hash']) ?? ''}`,
61
+ decidedBy: RouteDecidedBy.CHANGE,
62
+ data: { ...last.data, decidedBy: RouteDecidedBy.CHANGE },
63
+ }
64
+ : currentUrl === undefined || 0 === currentUrl.length
65
+ ? undefined
66
+ : readCurrentRoute(currentUrl);
67
+ if (reading === undefined) {
68
+ return {
69
+ pass: false,
70
+ failureReason: 'no route change observed',
71
+ observed: 'no route change in the window',
72
+ expected: `a route change to ${p.pathname ?? p.contains ?? 'any route'}`,
73
+ assertion: 'route.changed',
74
+ };
75
+ }
76
+ const pathname = reading.pathname;
77
+ if (p.pathname !== undefined && pathname !== p.pathname) {
78
+ return {
79
+ pass: false,
80
+ failureReason: `${describeRouteSource(reading)} is '${pathname}', expected '${p.pathname}'`,
81
+ observed: `${describeRouteSource(reading)} '${pathname}'`,
82
+ expected: `route '${p.pathname}'`,
83
+ assertion: 'route.pathname',
84
+ };
85
+ }
86
+ // `contains` matches the WHOLE route — path + query + fragment — while `pathname` above stays an
87
+ // exact path match. A hash router keeps the entire route in the fragment, so matching pathname
88
+ // alone made `contains` unsatisfiable for every HashRouter app; that is the standard router for a
89
+ // packaged Electron/Tauri renderer, where an absolute pushState would rewrite the URL to a
90
+ // nonexistent file.
91
+ const fullRoute = reading.full;
92
+ if (p.contains !== undefined && !fullRoute.includes(p.contains)) {
93
+ return {
94
+ pass: false,
95
+ failureReason: `${describeRouteSource(reading)} '${fullRoute}' does not contain '${p.contains}'`,
96
+ observed: `${describeRouteSource(reading)} '${fullRoute}'`,
97
+ expected: `a route containing '${p.contains}'`,
98
+ assertion: 'route.contains',
99
+ };
100
+ }
101
+ return { pass: true, evidence: reading.data };
102
+ }