@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.
- package/README.md +14 -5
- package/SKILL.md +130 -778
- package/dist/bridge/bridge.d.ts +23 -0
- package/dist/bridge/bridge.js +80 -2
- package/dist/bridge/bridge.test-harness.d.ts +5 -0
- package/dist/bridge/bridge.test-harness.js +6 -4
- package/dist/capsule/causal-summary.d.ts +36 -1
- package/dist/capsule/causal-summary.js +33 -7
- package/dist/capsule/divergence.js +7 -1
- package/dist/cli/chromium-hint.d.ts +33 -5
- package/dist/cli/chromium-hint.js +82 -12
- package/dist/cli/cli-doctor.js +65 -34
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-kill.d.ts +58 -0
- package/dist/cli/cli-kill.js +162 -0
- package/dist/cli/cli-parse.d.ts +16 -1
- package/dist/cli/cli-parse.js +32 -9
- package/dist/cli/cli-port.d.ts +32 -0
- package/dist/cli/cli-port.js +82 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +47 -3
- package/dist/cli/config-discovery.d.ts +35 -0
- package/dist/cli/config-discovery.js +163 -0
- package/dist/cli/doctor-daemon-line.js +4 -1
- package/dist/cli/doctor-project-line.d.ts +37 -0
- package/dist/cli/doctor-project-line.js +35 -0
- package/dist/cli/doctor-rows.d.ts +51 -0
- package/dist/cli/doctor-rows.js +54 -0
- package/dist/cli/doctor-sessions-line.d.ts +12 -0
- package/dist/cli/doctor-sessions-line.js +69 -0
- package/dist/cli/drive-attach.d.ts +99 -0
- package/dist/cli/drive-attach.js +203 -0
- package/dist/cli/drive-command.d.ts +13 -0
- package/dist/cli/drive-command.js +103 -0
- package/dist/cli/open-note.d.ts +20 -0
- package/dist/cli/open-note.js +37 -0
- package/dist/cli/port-holder.d.ts +10 -0
- package/dist/cli/port-holder.js +26 -2
- package/dist/cli/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +119 -39
- package/dist/crawl/crawl-tools.js +12 -2
- package/dist/crawl/crawl.d.ts +13 -0
- package/dist/crawl/crawl.js +50 -2
- package/dist/events/contradictions.d.ts +46 -0
- package/dist/events/contradictions.js +35 -22
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +8 -2
- package/dist/events/echo-mismatch.js +33 -5
- package/dist/events/event-filters.d.ts +1 -1
- package/dist/events/event-filters.js +7 -4
- package/dist/events/predicate-eval.d.ts +17 -72
- package/dist/events/predicate-eval.js +117 -236
- package/dist/events/predicate-parse.js +31 -3
- package/dist/events/predicate-route.d.ts +32 -0
- package/dist/events/predicate-route.js +102 -0
- package/dist/events/predicate-schema.d.ts +99 -0
- package/dist/events/predicate-schema.js +309 -0
- package/dist/events/predicate.d.ts +8 -0
- package/dist/events/predicate.js +296 -22
- package/dist/flows/annotate-tools.js +3 -0
- package/dist/flows/annotate.js +18 -4
- package/dist/flows/coverage.d.ts +38 -5
- package/dist/flows/coverage.js +30 -4
- package/dist/flows/decision.d.ts +15 -0
- package/dist/flows/decision.js +1 -1
- package/dist/flows/flow-classify.js +1 -1
- package/dist/flows/flow-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +21 -5
- package/dist/flows/heal-run.js +1 -1
- package/dist/flows/replay.js +3 -0
- package/dist/honesty/accepted-write.d.ts +3 -0
- package/dist/honesty/accepted-write.js +8 -3
- package/dist/honesty/already-true.js +15 -0
- package/dist/honesty/blind-spots.js +10 -2
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +1 -0
- package/dist/honesty/uncaptured-bodies.d.ts +4 -2
- package/dist/honesty/uncaptured-bodies.js +9 -2
- package/dist/honesty/unread-outcome.d.ts +8 -0
- package/dist/honesty/unread-outcome.js +36 -9
- package/dist/honesty/unsettled.d.ts +49 -0
- package/dist/honesty/unsettled.js +82 -0
- package/dist/honesty/verified.d.ts +36 -3
- package/dist/honesty/verified.js +54 -11
- package/dist/http-server.d.ts +19 -0
- package/dist/http-server.js +202 -4
- package/dist/impact/impact-recorder.d.ts +23 -0
- package/dist/impact/impact-recorder.js +76 -0
- package/dist/impact/impact-store.d.ts +47 -0
- package/dist/impact/impact-store.js +171 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +47 -10
- package/dist/init/agent-rules.d.ts +36 -0
- package/dist/init/agent-rules.js +132 -22
- package/dist/init/astro-patch.d.ts +2 -1
- package/dist/init/astro-patch.js +38 -6
- package/dist/init/capabilities.d.ts +22 -0
- package/dist/init/capabilities.js +76 -0
- package/dist/init/confirm.d.ts +114 -0
- package/dist/init/confirm.js +165 -0
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- package/dist/init/cra.js +3 -2
- package/dist/init/csp-check.d.ts +38 -0
- package/dist/init/csp-check.js +92 -0
- package/dist/init/csp-doctor.d.ts +25 -0
- package/dist/init/csp-doctor.js +49 -0
- package/dist/init/cursor.js +6 -9
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +33 -3
- package/dist/init/mcp-clients.d.ts +1 -0
- package/dist/init/mcp-clients.js +51 -7
- package/dist/init/mcp.d.ts +15 -0
- package/dist/init/mcp.js +19 -0
- package/dist/init/non-js-project.js +29 -5
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +78 -16
- package/dist/init/plan.d.ts +47 -6
- package/dist/init/plan.js +228 -69
- package/dist/init/run.d.ts +24 -2
- package/dist/init/run.js +157 -45
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +54 -6
- package/dist/init/snippets.d.ts +45 -5
- package/dist/init/snippets.js +194 -42
- package/dist/init/vite-config.js +53 -5
- package/dist/input/network-mock-tools.d.ts +19 -0
- package/dist/input/network-mock-tools.js +37 -5
- package/dist/input/scroll-find.d.ts +6 -0
- package/dist/input/scroll-find.js +17 -2
- package/dist/input/scroll-tools.js +4 -0
- package/dist/journal/attach-journal.d.ts +9 -1
- package/dist/journal/attach-journal.js +13 -2
- package/dist/journal/journal-query.d.ts +13 -0
- package/dist/journal/journal-query.js +28 -3
- package/dist/journal/workspace-gitignore.d.ts +10 -0
- package/dist/journal/workspace-gitignore.js +59 -0
- package/dist/license/license.js +1 -1
- package/dist/mcp/mcp-proxy.d.ts +35 -30
- package/dist/mcp/mcp-proxy.js +306 -104
- package/dist/mcp/mcp.d.ts +10 -1
- package/dist/mcp/mcp.js +70 -34
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +109 -2
- package/dist/mcp/proxy-lifecycle.d.ts +11 -2
- package/dist/mcp/proxy-lifecycle.js +12 -3
- package/dist/mcp/proxy-log.d.ts +63 -0
- package/dist/mcp/proxy-log.js +132 -0
- package/dist/mcp/server-instructions.d.ts +17 -0
- package/dist/mcp/server-instructions.js +56 -0
- package/dist/mcp/shared-params.d.ts +27 -0
- package/dist/mcp/shared-params.js +36 -0
- package/dist/pool/browser-pool.d.ts +22 -2
- package/dist/pool/browser-pool.js +44 -4
- package/dist/project/learned-routes.d.ts +24 -0
- package/dist/project/learned-routes.js +63 -0
- package/dist/project/project-store.d.ts +6 -0
- package/dist/project/project-store.js +25 -1
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/session/connection-memory.d.ts +59 -0
- package/dist/session/connection-memory.js +108 -0
- package/dist/session/dev-command.d.ts +26 -0
- package/dist/session/dev-command.js +89 -0
- package/dist/session/dev-server-probe.d.ts +68 -0
- package/dist/session/dev-server-probe.js +102 -7
- package/dist/session/last-act.d.ts +26 -0
- package/dist/session/last-act.js +35 -1
- package/dist/session/live-control-tools.js +42 -5
- package/dist/session/no-session-diagnosis.d.ts +67 -2
- package/dist/session/no-session-diagnosis.js +236 -27
- package/dist/session/no-session-next-action.d.ts +46 -0
- package/dist/session/no-session-next-action.js +85 -0
- package/dist/session/no-session-watch.d.ts +40 -1
- package/dist/session/no-session-watch.js +174 -8
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/ref-provenance.d.ts +54 -0
- package/dist/session/ref-provenance.js +61 -0
- package/dist/session/session-lease.js +1 -1
- package/dist/session/session-manager.d.ts +4 -0
- package/dist/session/session-manager.js +35 -5
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.js +146 -5
- package/dist/telemetry/app-instrumented.d.ts +8 -0
- package/dist/telemetry/app-instrumented.js +10 -0
- package/dist/telemetry/automation-hint.d.ts +17 -0
- package/dist/telemetry/automation-hint.js +71 -0
- package/dist/telemetry/bug-found.d.ts +15 -17
- package/dist/telemetry/bug-found.js +52 -21
- package/dist/telemetry/cli-telemetry.js +7 -1
- package/dist/telemetry/daemon-telemetry.js +24 -0
- package/dist/telemetry/feedback.js +1 -1
- package/dist/telemetry/init-telemetry.js +9 -1
- package/dist/telemetry/install-source.d.ts +56 -0
- package/dist/telemetry/install-source.js +74 -0
- package/dist/telemetry/instrumentation-stall.d.ts +50 -0
- package/dist/telemetry/instrumentation-stall.js +90 -0
- package/dist/telemetry/run-telemetry.js +4 -1
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +15 -0
- package/dist/telemetry/telemetry.d.ts +7 -1
- package/dist/telemetry/telemetry.js +26 -2
- package/dist/telemetry/tool-refused.d.ts +19 -0
- package/dist/telemetry/tool-refused.js +55 -0
- package/dist/telemetry/verification-of.js +46 -7
- package/dist/temp-dir.d.ts +27 -0
- package/dist/temp-dir.js +46 -0
- package/dist/tools/act-tools.js +209 -28
- package/dist/tools/assert-grade.d.ts +1 -1
- package/dist/tools/assert-grade.js +27 -5
- package/dist/tools/assert-verdict.js +49 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/contract-tools.js +4 -0
- package/dist/tools/dynamic-tools.js +16 -7
- package/dist/tools/error-recovery.d.ts +10 -0
- package/dist/tools/error-recovery.js +73 -9
- package/dist/tools/feedback-invite.js +1 -1
- package/dist/tools/invoke-tool.js +159 -26
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +40 -1
- package/dist/tools/lease-tools.js +181 -83
- package/dist/tools/navigate-result.js +1 -1
- package/dist/tools/observe-tools.js +10 -4
- package/dist/tools/observed-nothing.js +1 -1
- package/dist/tools/react-grace.d.ts +52 -0
- package/dist/tools/react-grace.js +90 -0
- package/dist/tools/read-tools.js +67 -5
- package/dist/tools/resolve-target.d.ts +33 -0
- package/dist/tools/resolve-target.js +53 -0
- package/dist/tools/settle-in-flight.d.ts +94 -0
- package/dist/tools/settle-in-flight.js +146 -0
- package/dist/tools/snapshot-delta.d.ts +10 -0
- package/dist/tools/snapshot-delta.js +194 -9
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +86 -1
- package/dist/tools/tools.js +61 -8
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +55 -11
- package/docs/actions.mdx +120 -0
- package/docs/agent-cheatsheet.md +95 -29
- package/docs/architecture.md +52 -35
- package/docs/benchmarks.md +127 -75
- package/docs/best-practices.mdx +216 -0
- package/docs/capabilities.mdx +534 -0
- package/docs/cli/affected.mdx +53 -0
- package/docs/cli/capsules.mdx +70 -0
- package/docs/cli/cloud.mdx +153 -0
- package/docs/cli/doctor.mdx +109 -0
- package/docs/cli/drive.mdx +74 -0
- package/docs/cli/feedback.mdx +67 -0
- package/docs/cli/gate.mdx +64 -0
- package/docs/cli/help.mdx +94 -0
- package/docs/cli/hunt.mdx +73 -0
- package/docs/cli/identify.mdx +76 -0
- package/docs/cli/init.mdx +121 -0
- package/docs/cli/kill.mdx +91 -0
- package/docs/cli/license.mdx +48 -0
- package/docs/cli/mcp.mdx +61 -0
- package/docs/cli/open.mdx +74 -0
- package/docs/cli/restart.mdx +60 -0
- package/docs/cli/rollback.mdx +35 -0
- package/docs/cli/serve.mdx +69 -0
- package/docs/cli/status.mdx +88 -0
- package/docs/cli/stop.mdx +64 -0
- package/docs/cli/telemetry.mdx +57 -0
- package/docs/cli/update.mdx +52 -0
- package/docs/cli/verify.mdx +76 -0
- package/docs/cli/version.mdx +39 -0
- package/docs/cli/watch.mdx +62 -0
- package/docs/cli.mdx +241 -0
- package/docs/debugging.md +16 -10
- package/docs/deploy-checks.md +45 -12
- package/docs/desktop.mdx +384 -0
- package/docs/docs.json +364 -0
- package/docs/enterprise.md +22 -16
- package/docs/faq.mdx +102 -0
- package/docs/fixtures.md +13 -7
- package/docs/flows.md +29 -23
- package/docs/for-agents.md +71 -0
- package/docs/frameworks.mdx +238 -0
- package/docs/gate-plan.md +48 -42
- package/docs/gates.md +28 -22
- package/docs/getting-started.md +129 -77
- package/docs/human-control.md +24 -18
- package/docs/index.mdx +143 -0
- package/docs/install-agentic.mdx +224 -0
- package/docs/install-manual.mdx +196 -0
- package/docs/instrumentation.mdx +238 -0
- package/docs/integration-patterns.md +41 -35
- package/docs/local-registry.md +19 -13
- package/docs/multi-agent-testing.md +19 -13
- package/docs/packages/babel-plugin.mdx +79 -0
- package/docs/packages/browser.mdx +111 -0
- package/docs/packages/core.mdx +116 -0
- package/docs/packages/electron.mdx +85 -0
- package/docs/packages/eslint-plugin.mdx +86 -0
- package/docs/packages/next.mdx +89 -0
- package/docs/packages/react.mdx +91 -0
- package/docs/packages/server.mdx +98 -0
- package/docs/packages/tauri.mdx +63 -0
- package/docs/packages/test.mdx +119 -0
- package/docs/packages/vite-plugin.mdx +94 -0
- package/docs/packages.mdx +218 -0
- package/docs/platform-integration.md +35 -26
- package/docs/predicates.mdx +248 -0
- package/docs/quickstart.mdx +257 -0
- package/docs/recipes.mdx +282 -0
- package/docs/skill-file.mdx +107 -0
- package/docs/state-management.mdx +199 -0
- package/docs/style.css +14 -0
- package/docs/system-map.md +50 -33
- package/docs/telemetry-contract.md +106 -52
- package/docs/telemetry-events.mdx +475 -0
- package/docs/telemetry.md +63 -55
- package/docs/testing.md +44 -6
- package/docs/token-efficiency.md +37 -27
- package/docs/tools-act-and-wait.mdx +173 -0
- package/docs/tools-act-sequence.mdx +137 -0
- package/docs/tools-act.mdx +95 -0
- package/docs/tools-assert.mdx +129 -0
- package/docs/tools-console.mdx +63 -0
- package/docs/tools-inspect.mdx +88 -0
- package/docs/tools-navigate.mdx +99 -0
- package/docs/tools-network.mdx +119 -0
- package/docs/tools-observe.mdx +145 -0
- package/docs/tools-overview.mdx +160 -0
- package/docs/tools-query.mdx +126 -0
- package/docs/tools-session-and-feedback.mdx +112 -0
- package/docs/tools-sessions.mdx +94 -0
- package/docs/tools-snapshot.mdx +129 -0
- package/docs/tools-state.mdx +104 -0
- package/docs/tools-tools-and-run.mdx +156 -0
- package/docs/tools-wait-for.mdx +85 -0
- package/docs/troubleshooting.mdx +263 -0
- package/docs/usage.md +209 -165
- package/docs/vs-chrome-devtools-mcp.mdx +123 -0
- package/docs/vs-playwright-mcp.mdx +125 -0
- package/docs/vs-screenshots.mdx +137 -0
- package/docs/what-is-reticle.mdx +63 -0
- package/docs/why-reticle.mdx +109 -0
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -256
- package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
- package/docs/matrix/2.5.0/codex-machine.json +0 -20
- package/docs/matrix/2.5.0/cursor-machine.json +0 -22
- package/docs/matrix/2.5.0/gemini-machine.json +0 -22
- package/docs/matrix/2.5.0/opencode-machine.json +0 -22
- package/docs/matrix/2.5.0/vscode-machine.json +0 -22
- package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
- package/docs/matrix/MATRIX.md +0 -26
- package/docs/matrix/README.md +0 -59
- package/docs/mint.json +0 -61
|
@@ -1,197 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|
-
/**
|
|
19
|
-
|
|
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
|
|
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
|
+
}
|