@reticlehq/server 2.8.0 → 2.10.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/SKILL.md +130 -813
- 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/capsule-store.d.ts +2 -2
- package/dist/capsule/causal-summary.d.ts +16 -1
- package/dist/capsule/causal-summary.js +2 -1
- package/dist/cli/cli-doctor.js +41 -13
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-launch.d.ts +0 -17
- package/dist/cli/cli-launch.js +0 -28
- package/dist/cli/cli-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- package/dist/cli/cli-port.d.ts +24 -0
- package/dist/cli/cli-port.js +66 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +45 -1
- 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.js +6 -3
- 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/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +65 -70
- package/dist/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- package/dist/ee/audit-log.js +2 -2
- package/dist/events/body-failures.d.ts +2 -2
- package/dist/events/contradiction-folds.d.ts +45 -0
- package/dist/events/contradiction-folds.js +42 -0
- package/dist/events/contradictions.d.ts +51 -1
- package/dist/events/contradictions.js +26 -7
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +9 -3
- 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 -91
- package/dist/events/predicate-eval.js +85 -326
- 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 +127 -5
- package/dist/events/stale-response.d.ts +2 -3
- package/dist/events/stale-response.js +49 -2
- package/dist/events/unit-mismatch.d.ts +2 -2
- 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-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +9 -1
- package/dist/flows/replay.js +3 -0
- package/dist/flows/verify-change-tools.js +1 -1
- 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 +4 -0
- 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 +9 -0
- package/dist/honesty/unsettled.js +7 -3
- package/dist/honesty/verified.d.ts +29 -3
- package/dist/honesty/verified.js +67 -8
- 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 +25 -0
- package/dist/index.js +60 -10
- package/dist/init/agent-rules.d.ts +20 -0
- package/dist/init/agent-rules.js +36 -0
- package/dist/init/astro-patch.js +27 -1
- package/dist/init/confirm.d.ts +21 -3
- package/dist/init/confirm.js +32 -5
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- 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 +16 -2
- package/dist/init/mcp.js +23 -1
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +25 -1
- package/dist/init/plan.d.ts +7 -2
- package/dist/init/plan.js +66 -30
- package/dist/init/run.js +11 -5
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +12 -4
- package/dist/init/snippets.d.ts +1 -1
- package/dist/init/snippets.js +35 -7
- package/dist/init/vite-config.js +24 -2
- package/dist/input/network-mock-tools.js +8 -1
- 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.d.ts +69 -4
- package/dist/license/license.js +84 -15
- package/dist/mcp/mcp-proxy.d.ts +7 -93
- package/dist/mcp/mcp-proxy.js +183 -250
- package/dist/mcp/mcp.d.ts +27 -2
- package/dist/mcp/mcp.js +77 -36
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +110 -3
- package/dist/mcp/proxy-lifecycle.d.ts +10 -1
- package/dist/mcp/proxy-lifecycle.js +11 -2
- 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/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -0
- 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 +55 -2
- package/dist/session/no-session-diagnosis.js +199 -22
- 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 +172 -11
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/session-manager.d.ts +4 -0
- package/dist/session/session-manager.js +35 -5
- package/dist/session/session-readiness.js +1 -1
- 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/bug-found.d.ts +18 -0
- package/dist/telemetry/bug-found.js +14 -0
- package/dist/telemetry/daemon-telemetry.js +29 -0
- package/dist/telemetry/install-source.d.ts +20 -0
- package/dist/telemetry/install-source.js +34 -0
- package/dist/telemetry/instrumentation-stall.d.ts +57 -0
- package/dist/telemetry/instrumentation-stall.js +103 -0
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -0
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +10 -0
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +23 -3
- package/dist/tools/act-tools.js +166 -29
- package/dist/tools/assert-verdict.js +32 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/dynamic-tools.js +17 -8
- package/dist/tools/error-recovery.js +10 -6
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +30 -8
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +11 -0
- package/dist/tools/lease-tools.js +141 -96
- package/dist/tools/observe-tools.js +9 -3
- package/dist/tools/react-grace.d.ts +4 -0
- package/dist/tools/react-grace.js +6 -3
- package/dist/tools/read-tools.js +47 -1
- 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 +15 -0
- package/dist/tools/settle-in-flight.js +42 -2
- package/dist/tools/snapshot-delta.d.ts +3 -0
- package/dist/tools/snapshot-delta.js +67 -6
- package/dist/tools/tool-kit.d.ts +38 -3
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +84 -19
- package/dist/tools/tools.js +61 -5
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +1 -2
- package/docs/actions.mdx +41 -8
- package/docs/agent-cheatsheet.md +6 -4
- package/docs/architecture.md +6 -0
- package/docs/benchmarks.md +60 -12
- package/docs/best-practices.mdx +11 -6
- package/docs/capabilities.mdx +204 -57
- package/docs/cli/affected.mdx +6 -4
- package/docs/cli/capsules.mdx +9 -8
- package/docs/cli/cloud.mdx +30 -4
- package/docs/cli/doctor.mdx +41 -8
- package/docs/cli/drive.mdx +34 -15
- package/docs/cli/gate.mdx +6 -6
- package/docs/cli/help.mdx +8 -6
- package/docs/cli/hunt.mdx +20 -4
- package/docs/cli/identify.mdx +16 -2
- package/docs/cli/init.mdx +35 -8
- package/docs/cli/kill.mdx +5 -5
- package/docs/cli/license.mdx +1 -1
- package/docs/cli/open.mdx +6 -0
- package/docs/cli/restart.mdx +6 -6
- package/docs/cli/serve.mdx +10 -3
- package/docs/cli/status.mdx +29 -5
- package/docs/cli/stop.mdx +8 -0
- package/docs/cli/verify.mdx +20 -1
- package/docs/cli/version.mdx +1 -1
- package/docs/cli/watch.mdx +9 -2
- package/docs/cli.mdx +44 -22
- package/docs/debugging.md +3 -1
- package/docs/deploy-checks.md +31 -2
- package/docs/desktop.mdx +276 -80
- package/docs/docs.json +13 -12
- package/docs/enterprise.md +9 -3
- package/docs/faq.mdx +49 -35
- package/docs/fixtures.md +2 -0
- package/docs/flows.md +7 -5
- package/docs/for-agents.md +3 -3
- package/docs/frameworks.mdx +46 -7
- package/docs/gate-plan.md +4 -2
- package/docs/gates.md +8 -6
- package/docs/getting-started.md +53 -12
- package/docs/human-control.md +2 -0
- package/docs/index.mdx +43 -1
- package/docs/install-agentic.mdx +13 -4
- package/docs/install-manual.mdx +6 -4
- package/docs/instrumentation.mdx +7 -1
- package/docs/integration-patterns.md +3 -1
- package/docs/local-registry.md +4 -2
- package/docs/multi-agent-testing.md +3 -1
- package/docs/packages/babel-plugin.mdx +15 -6
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/core.mdx +26 -16
- package/docs/packages/electron.mdx +5 -7
- package/docs/packages/eslint-plugin.mdx +5 -3
- package/docs/packages/next.mdx +3 -5
- package/docs/packages/react.mdx +3 -3
- package/docs/packages/server.mdx +2 -2
- package/docs/packages/tauri.mdx +6 -6
- package/docs/packages/test.mdx +18 -7
- package/docs/packages/vite-plugin.mdx +4 -6
- package/docs/packages.mdx +14 -13
- package/docs/platform-integration.md +2 -0
- package/docs/predicates.mdx +76 -7
- package/docs/quickstart.mdx +51 -5
- package/docs/recipes.mdx +63 -23
- package/docs/skill-file.mdx +3 -1
- package/docs/state-management.mdx +2 -0
- package/docs/system-map.md +3 -1
- package/docs/telemetry-contract.md +33 -5
- package/docs/telemetry-events.mdx +63 -18
- package/docs/telemetry.md +8 -5
- package/docs/testing.md +35 -1
- package/docs/token-efficiency.md +8 -2
- package/docs/tools-act-and-wait.mdx +64 -14
- package/docs/tools-act-sequence.mdx +34 -17
- package/docs/tools-act.mdx +24 -11
- package/docs/tools-assert.mdx +69 -10
- package/docs/tools-console.mdx +11 -7
- package/docs/tools-inspect.mdx +11 -8
- package/docs/tools-navigate.mdx +41 -5
- package/docs/tools-network.mdx +42 -9
- package/docs/tools-observe.mdx +66 -10
- package/docs/tools-overview.mdx +26 -23
- package/docs/tools-query.mdx +52 -8
- package/docs/tools-session-and-feedback.mdx +35 -9
- package/docs/tools-sessions.mdx +39 -10
- package/docs/tools-snapshot.mdx +57 -3
- package/docs/tools-state.mdx +24 -1
- package/docs/tools-tools-and-run.mdx +55 -24
- package/docs/tools-wait-for.mdx +34 -15
- package/docs/troubleshooting.mdx +80 -10
- package/docs/usage.md +46 -24
- package/docs/vs-chrome-devtools-mcp.mdx +41 -0
- package/docs/vs-playwright-mcp.mdx +42 -2
- package/docs/vs-screenshots.mdx +42 -1
- package/docs/what-is-reticle.mdx +1 -1
- package/docs/why-reticle.mdx +3 -1
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -265
|
@@ -1,289 +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
|
-
/**
|
|
47
|
-
* The locator fields the browser actually CONSUMES for a given query — mirrors the precedence in
|
|
48
|
-
* `findIn` (packages/browser/src/dom/query.ts).
|
|
49
|
-
*
|
|
50
|
-
* An element query is not a conjunction. It is a first-match dispatch: `by`+`value` wins, then the
|
|
51
|
-
* component/source anchor, then `role` (which alone also consumes `name`), then the first of
|
|
52
|
-
* text/label/placeholder/testid/alt that is present. Every OTHER field the caller wrote is dropped on
|
|
53
|
-
* the floor, silently, and the match reported as if the whole query had been honoured.
|
|
54
|
-
*
|
|
55
|
-
* Duplicated here on purpose. The alternative is to send the question to the browser, and the browser
|
|
56
|
-
* cannot answer it: by the time a match comes back, the fields it ignored are indistinguishable from
|
|
57
|
-
* the fields it used.
|
|
58
|
-
*/
|
|
59
|
-
function usedQueryFields(query) {
|
|
60
|
-
const used = new Set();
|
|
61
|
-
if (query.by !== undefined && query.value !== undefined) {
|
|
62
|
-
used.add('by').add('value');
|
|
63
|
-
if (QueryBy.ROLE === query.by)
|
|
64
|
-
used.add('name');
|
|
65
|
-
if (QueryBy.COMPONENT === query.by)
|
|
66
|
-
used.add('component');
|
|
67
|
-
return used;
|
|
68
|
-
}
|
|
69
|
-
if (query.component !== undefined || query.source !== undefined)
|
|
70
|
-
return used.add('component');
|
|
71
|
-
if (query.role !== undefined)
|
|
72
|
-
return used.add('role').add('name');
|
|
73
|
-
for (const field of ['text', 'label', 'placeholder', 'testid', 'alt']) {
|
|
74
|
-
if (query[field] !== undefined)
|
|
75
|
-
return used.add(field);
|
|
76
|
-
}
|
|
77
|
-
return used;
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* How a dropped field is checked back on the server, against the descriptor the match returned.
|
|
81
|
-
*
|
|
82
|
-
* `value` is the field this exists for: `{ role: "textbox", name: "GST amount", value: "274.58" }`
|
|
83
|
-
* read as a locator has no `by`, so the value half was discarded and the predicate collapsed to "a
|
|
84
|
-
* textbox named GST amount exists" — trivially true against an EMPTY field. Comparison is TRIMMED and
|
|
85
|
-
* exact: an input's value is a value, not prose, and a trailing space in either the app or the
|
|
86
|
-
* predicate is not a finding anybody wants. `""` asserts the field is empty, which describe() reports
|
|
87
|
-
* by omitting the field entirely.
|
|
88
|
-
*
|
|
89
|
-
* `role`/`name`/`value` compare against exactly what `reticle_query` REPORTS, so the words an agent
|
|
90
|
-
* copies out of a query result are the words that match here. `text` is a substring match, matching
|
|
91
|
-
* Testing Library's `exact: false`, and falls back to the name because describe() omits `text` when it
|
|
92
|
-
* equals the accessible name.
|
|
93
|
-
*/
|
|
94
|
-
const RESIDUAL_CHECKS = {
|
|
95
|
-
value: (element, want) => (element.value ?? '').trim() === want.trim(),
|
|
96
|
-
role: (element, want) => element.role === want,
|
|
97
|
-
name: (element, want) => element.name.trim() === want.trim(),
|
|
98
|
-
text: (element, want) => (element.text ?? element.name).includes(want),
|
|
99
|
-
};
|
|
100
|
-
/**
|
|
101
|
-
* Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
|
|
102
|
-
*
|
|
103
|
-
* The alternative — refuse every dropped field — breaks calls that work today and that our own
|
|
104
|
-
* cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
|
|
105
|
-
* Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
|
|
106
|
-
* outcome the caller was already assuming.
|
|
107
|
-
*/
|
|
108
|
-
export function residualQueryChecks(query) {
|
|
109
|
-
const used = usedQueryFields(query);
|
|
110
|
-
const checks = [];
|
|
111
|
-
const unusable = [];
|
|
112
|
-
for (const field of ELEMENT_QUERY_FIELDS) {
|
|
113
|
-
const want = query[field];
|
|
114
|
-
if (want === undefined || used.has(field))
|
|
115
|
-
continue;
|
|
116
|
-
if ('string' === typeof want && RESIDUAL_CHECKS[field] !== undefined)
|
|
117
|
-
checks.push([field, want]);
|
|
118
|
-
else
|
|
119
|
-
unusable.push(field);
|
|
120
|
-
}
|
|
121
|
-
return { checks, unusable };
|
|
122
|
-
}
|
|
123
|
-
/** Does this element satisfy every field the locator dropped? */
|
|
124
|
-
export function satisfiesResiduals(element, checks) {
|
|
125
|
-
return checks.every(([field, want]) => true === RESIDUAL_CHECKS[field]?.(element, want));
|
|
126
|
-
}
|
|
127
|
-
/** How the element's own reading of a dropped field should be REPORTED back on a failure. */
|
|
128
|
-
export function describeResidual(element, field) {
|
|
129
|
-
const reading = 'value' === field
|
|
130
|
-
? (element.value ?? '')
|
|
131
|
-
: 'text' === field
|
|
132
|
-
? (element.text ?? element.name)
|
|
133
|
-
: 'role' === field
|
|
134
|
-
? element.role
|
|
135
|
-
: element.name;
|
|
136
|
-
return `${element.role} "${element.name}" ${field}=${JSON.stringify(reading)}`;
|
|
137
|
-
}
|
|
138
|
-
/** Rename known aliases before parse; an explicit canonical key always wins. */
|
|
139
|
-
function applyPredicateAliases(input) {
|
|
140
|
-
if (typeof input !== 'object' || null === input || Array.isArray(input))
|
|
141
|
-
return input;
|
|
142
|
-
const obj = input;
|
|
143
|
-
const kind = 'string' === typeof obj['kind'] ? obj['kind'] : '';
|
|
144
|
-
const aliases = PREDICATE_ALIASES[kind];
|
|
145
|
-
let out = obj;
|
|
146
|
-
if (aliases !== undefined) {
|
|
147
|
-
out = { ...obj };
|
|
148
|
-
for (const [from, to] of Object.entries(aliases)) {
|
|
149
|
-
if (out[from] === undefined)
|
|
150
|
-
continue;
|
|
151
|
-
if (out[to] === undefined)
|
|
152
|
-
out[to] = out[from];
|
|
153
|
-
delete out[from];
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return PredicateKind.ELEMENT === kind ? liftElementQuery(out) : out;
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Fold flat query fields into `query`. An explicit `query` wins outright — a caller that supplied
|
|
160
|
-
* both told us which one it meant, and merging the two would invent a locator neither side wrote.
|
|
161
|
-
*/
|
|
162
|
-
function liftElementQuery(obj) {
|
|
163
|
-
const loose = ELEMENT_QUERY_FIELDS.filter((field) => obj[field] !== undefined);
|
|
164
|
-
if (0 === loose.length)
|
|
165
|
-
return obj;
|
|
166
|
-
const out = { ...obj };
|
|
167
|
-
const query = {};
|
|
168
|
-
for (const field of loose) {
|
|
169
|
-
query[field] = out[field];
|
|
170
|
-
delete out[field];
|
|
171
|
-
}
|
|
172
|
-
if (out['query'] === undefined)
|
|
173
|
-
out['query'] = query;
|
|
174
|
-
return out;
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
|
|
178
|
-
* a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
|
|
179
|
-
*
|
|
180
|
-
* Built on demand rather than at module scope: the `allOf`/`anyOf`/`not` branches reference
|
|
181
|
-
* `PredicateSchema` itself, which does not exist yet while this module is initialising. Calling it
|
|
182
|
-
* after init is what makes the union introspectable — see `predicateFieldsFor`.
|
|
183
|
-
*/
|
|
184
|
-
function predicateUnion() {
|
|
185
|
-
return z.discriminatedUnion('kind', [
|
|
186
|
-
z
|
|
187
|
-
.object({
|
|
188
|
-
kind: z.literal(PredicateKind.ELEMENT),
|
|
189
|
-
query: ElementQuerySchema,
|
|
190
|
-
state: z.nativeEnum(ElementState).optional(),
|
|
191
|
-
absent: z.boolean().optional(),
|
|
192
|
-
})
|
|
193
|
-
.strict(),
|
|
194
|
-
z
|
|
195
|
-
.object({
|
|
196
|
-
kind: z.literal(PredicateKind.TEXT),
|
|
197
|
-
contains: z.string(),
|
|
198
|
-
visible: z.boolean().optional(),
|
|
199
|
-
absent: z.boolean().optional(),
|
|
200
|
-
})
|
|
201
|
-
.strict(),
|
|
202
|
-
z
|
|
203
|
-
.object({
|
|
204
|
-
kind: z.literal(PredicateKind.NET),
|
|
205
|
-
method: z.string().optional(),
|
|
206
|
-
urlContains: z.string().optional(),
|
|
207
|
-
status: z.number().optional(),
|
|
208
|
-
ok: z.boolean().optional(),
|
|
209
|
-
since: z.number().optional(),
|
|
210
|
-
count: z.number().int().nonnegative().optional(),
|
|
211
|
-
})
|
|
212
|
-
.strict(),
|
|
213
|
-
z
|
|
214
|
-
.object({
|
|
215
|
-
kind: z.literal(PredicateKind.ROUTE),
|
|
216
|
-
pathname: z.string().optional(),
|
|
217
|
-
contains: z.string().optional(),
|
|
218
|
-
since: z.number().optional(),
|
|
219
|
-
})
|
|
220
|
-
.strict(),
|
|
221
|
-
z
|
|
222
|
-
.object({
|
|
223
|
-
kind: z.literal(PredicateKind.CONSOLE),
|
|
224
|
-
level: z.string().optional(),
|
|
225
|
-
absent: z.boolean().optional(),
|
|
226
|
-
since: z.number().optional(),
|
|
227
|
-
})
|
|
228
|
-
.strict(),
|
|
229
|
-
z
|
|
230
|
-
.object({
|
|
231
|
-
kind: z.literal(PredicateKind.ANIMATION),
|
|
232
|
-
name: z.string().optional(),
|
|
233
|
-
target: z.string().optional(),
|
|
234
|
-
completed: z.boolean().optional(),
|
|
235
|
-
since: z.number().optional(),
|
|
236
|
-
})
|
|
237
|
-
.strict(),
|
|
238
|
-
z
|
|
239
|
-
.object({
|
|
240
|
-
kind: z.literal(PredicateKind.SIGNAL),
|
|
241
|
-
name: z.string().optional(),
|
|
242
|
-
dataMatches: z.record(z.unknown()).optional(),
|
|
243
|
-
since: z.number().optional(),
|
|
244
|
-
})
|
|
245
|
-
.strict(),
|
|
246
|
-
z
|
|
247
|
-
.object({
|
|
248
|
-
kind: z.literal(PredicateKind.STATE),
|
|
249
|
-
store: z.string().optional(),
|
|
250
|
-
path: z.string(),
|
|
251
|
-
equals: z.unknown().optional(),
|
|
252
|
-
})
|
|
253
|
-
.strict(),
|
|
254
|
-
z
|
|
255
|
-
.object({
|
|
256
|
-
kind: z.literal(PredicateKind.SETTLED),
|
|
257
|
-
quietMs: z.number().positive().optional(),
|
|
258
|
-
})
|
|
259
|
-
.strict(),
|
|
260
|
-
z
|
|
261
|
-
.object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
|
|
262
|
-
.strict(),
|
|
263
|
-
z
|
|
264
|
-
.object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
|
|
265
|
-
.strict(),
|
|
266
|
-
z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
|
|
267
|
-
]);
|
|
268
|
-
}
|
|
269
|
-
export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, predicateUnion()));
|
|
270
|
-
/**
|
|
271
|
-
* The fields a given predicate kind accepts, read off the schema itself.
|
|
272
|
-
*
|
|
273
|
-
* Derived rather than listed so the two can never disagree: a rejection message that names a stale
|
|
274
|
-
* field set is worse than one that names none, because the agent trusts it and retries into the same
|
|
275
|
-
* wall. Empty for a kind that is not in the union.
|
|
276
|
-
*/
|
|
277
|
-
export function predicateFieldsFor(kind) {
|
|
278
|
-
for (const option of predicateUnion().options) {
|
|
279
|
-
const literal = option.shape['kind'];
|
|
280
|
-
if (literal instanceof z.ZodLiteral && literal.value === kind) {
|
|
281
|
-
return Object.keys(option.shape).filter((field) => 'kind' !== field);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
return [];
|
|
285
|
-
}
|
|
286
|
-
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) {
|
|
287
8
|
return 'string' === typeof value ? value : undefined;
|
|
288
9
|
}
|
|
289
10
|
function num(value) {
|
|
@@ -419,8 +140,46 @@ function describeCall(e) {
|
|
|
419
140
|
const status = num(e.data['status']);
|
|
420
141
|
return status === undefined ? head : `${head} → ${String(status)}`;
|
|
421
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
|
+
}
|
|
422
163
|
export function evalNet(events, p) {
|
|
423
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;
|
|
424
183
|
const matches = events.filter((e) => {
|
|
425
184
|
if (e.type !== EventType.NET_REQUEST || e.t < since)
|
|
426
185
|
return false;
|
|
@@ -435,8 +194,43 @@ export function evalNet(events, p) {
|
|
|
435
194
|
return false;
|
|
436
195
|
if (p.ok !== undefined && callSucceeded(d) !== p.ok)
|
|
437
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
|
+
}
|
|
438
212
|
return true;
|
|
439
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
|
+
}
|
|
440
234
|
// `count` (exact) turns presence into a cardinality assertion — catches the double-submit /
|
|
441
235
|
// useEffect-double-fire / retry-storm regression class, where the request DID fire (presence passes)
|
|
442
236
|
// but fired the WRONG number of times. Without `count`, the matcher is presence-only (≥1).
|
|
@@ -445,9 +239,13 @@ export function evalNet(events, p) {
|
|
|
445
239
|
? { pass: true, evidence: { matched: matches.length } }
|
|
446
240
|
: {
|
|
447
241
|
pass: false,
|
|
448
|
-
|
|
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)}`,
|
|
449
247
|
observed: `${String(matches.length)} matching network call(s)`,
|
|
450
|
-
expected: `exactly ${String(p.count)} matching ${
|
|
248
|
+
expected: `exactly ${String(p.count)} matching ${describeNetFilter(p)}`,
|
|
451
249
|
assertion: 'net.count',
|
|
452
250
|
};
|
|
453
251
|
}
|
|
@@ -464,45 +262,6 @@ export function evalNet(events, p) {
|
|
|
464
262
|
assertion: 'net.present',
|
|
465
263
|
};
|
|
466
264
|
}
|
|
467
|
-
export function evalRoute(events, p) {
|
|
468
|
-
const routes = events.filter((e) => e.type === EventType.ROUTE_CHANGE);
|
|
469
|
-
const last = routes.at(-1);
|
|
470
|
-
if (last === undefined) {
|
|
471
|
-
return {
|
|
472
|
-
pass: false,
|
|
473
|
-
failureReason: 'no route change observed',
|
|
474
|
-
observed: 'no route change in the window',
|
|
475
|
-
expected: `a route change to ${p.pathname ?? p.contains ?? 'any route'}`,
|
|
476
|
-
assertion: 'route.changed',
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
const pathname = str(last.data['pathname']) ?? str(last.data['to']) ?? '';
|
|
480
|
-
if (p.pathname !== undefined && pathname !== p.pathname) {
|
|
481
|
-
return {
|
|
482
|
-
pass: false,
|
|
483
|
-
failureReason: `route is '${pathname}', expected '${p.pathname}'`,
|
|
484
|
-
observed: `route '${pathname}'`,
|
|
485
|
-
expected: `route '${p.pathname}'`,
|
|
486
|
-
assertion: 'route.pathname',
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
// `contains` matches the WHOLE route — path + query + fragment — while `pathname` above stays an
|
|
490
|
-
// exact path match. A hash router keeps the entire route in the fragment, so matching pathname
|
|
491
|
-
// alone made `contains` unsatisfiable for every HashRouter app; that is the standard router for a
|
|
492
|
-
// packaged Electron/Tauri renderer, where an absolute pushState would rewrite the URL to a
|
|
493
|
-
// nonexistent file.
|
|
494
|
-
const fullRoute = `${pathname}${str(last.data['search']) ?? ''}${str(last.data['hash']) ?? ''}`;
|
|
495
|
-
if (p.contains !== undefined && !fullRoute.includes(p.contains)) {
|
|
496
|
-
return {
|
|
497
|
-
pass: false,
|
|
498
|
-
failureReason: `route '${fullRoute}' does not contain '${p.contains}'`,
|
|
499
|
-
observed: `route '${fullRoute}'`,
|
|
500
|
-
expected: `a route containing '${p.contains}'`,
|
|
501
|
-
assertion: 'route.contains',
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
return { pass: true, evidence: last.data };
|
|
505
|
-
}
|
|
506
265
|
/** The only console levels Reticle instruments (console.info/debug/trace are NOT patched). */
|
|
507
266
|
const CONSOLE_LEVEL_TYPE = {
|
|
508
267
|
log: EventType.CONSOLE_LOG,
|
|
@@ -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
|
+
}
|