@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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ElementState, PredicateKind, type ElementDescriptor, type ElementQuery } from '@reticlehq/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export type Predicate = {
|
|
4
|
+
kind: typeof PredicateKind.ELEMENT;
|
|
5
|
+
query: ElementQuery;
|
|
6
|
+
state?: ElementState;
|
|
7
|
+
absent?: boolean;
|
|
8
|
+
} | {
|
|
9
|
+
kind: typeof PredicateKind.TEXT;
|
|
10
|
+
contains: string;
|
|
11
|
+
visible?: boolean;
|
|
12
|
+
absent?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Restrict the match to a subtree, as a CSS selector or a ref — the same field, and the same
|
|
15
|
+
* meaning, as `scope` on an element query.
|
|
16
|
+
*
|
|
17
|
+
* Without it the match is page-wide, and a word that appears both in a background tab label
|
|
18
|
+
* and in the dialog that just opened satisfies the predicate BEFORE the action runs, so
|
|
19
|
+
* `act_and_wait` reports `already_true` for an action that did exactly the right thing.
|
|
20
|
+
*/
|
|
21
|
+
scope?: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: typeof PredicateKind.NET;
|
|
24
|
+
method?: string;
|
|
25
|
+
urlContains?: string;
|
|
26
|
+
status?: number;
|
|
27
|
+
/** Did the call succeed? The honest field for IPC, which has no status code. */
|
|
28
|
+
ok?: boolean;
|
|
29
|
+
since?: number;
|
|
30
|
+
count?: number;
|
|
31
|
+
/** A substring the RESPONSE body must contain — what the server answered, not what was sent. */
|
|
32
|
+
bodyContains?: string;
|
|
33
|
+
} | {
|
|
34
|
+
kind: typeof PredicateKind.ROUTE;
|
|
35
|
+
pathname?: string;
|
|
36
|
+
contains?: string;
|
|
37
|
+
since?: number;
|
|
38
|
+
} | {
|
|
39
|
+
kind: typeof PredicateKind.CONSOLE;
|
|
40
|
+
level?: string;
|
|
41
|
+
absent?: boolean;
|
|
42
|
+
since?: number;
|
|
43
|
+
} | {
|
|
44
|
+
kind: typeof PredicateKind.ANIMATION;
|
|
45
|
+
name?: string;
|
|
46
|
+
target?: string;
|
|
47
|
+
completed?: boolean;
|
|
48
|
+
since?: number;
|
|
49
|
+
} | {
|
|
50
|
+
kind: typeof PredicateKind.SIGNAL;
|
|
51
|
+
name?: string;
|
|
52
|
+
dataMatches?: Record<string, unknown>;
|
|
53
|
+
since?: number;
|
|
54
|
+
} | {
|
|
55
|
+
kind: typeof PredicateKind.STATE;
|
|
56
|
+
store?: string;
|
|
57
|
+
path: string;
|
|
58
|
+
equals?: unknown;
|
|
59
|
+
} | {
|
|
60
|
+
kind: typeof PredicateKind.SETTLED;
|
|
61
|
+
quietMs?: number;
|
|
62
|
+
} | {
|
|
63
|
+
kind: typeof PredicateKind.ALL_OF;
|
|
64
|
+
predicates: Predicate[];
|
|
65
|
+
} | {
|
|
66
|
+
kind: typeof PredicateKind.ANY_OF;
|
|
67
|
+
predicates: Predicate[];
|
|
68
|
+
} | {
|
|
69
|
+
kind: typeof PredicateKind.NOT;
|
|
70
|
+
predicate: Predicate;
|
|
71
|
+
};
|
|
72
|
+
export interface ResidualQueryChecks {
|
|
73
|
+
/** Dropped fields this side CAN check, as [field, wanted value] pairs. */
|
|
74
|
+
checks: [string, string][];
|
|
75
|
+
/** Dropped fields with no descriptor to check them against — refuse rather than ignore. */
|
|
76
|
+
unusable: string[];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
|
|
80
|
+
*
|
|
81
|
+
* The alternative — refuse every dropped field — breaks calls that work today and that our own
|
|
82
|
+
* cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
|
|
83
|
+
* Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
|
|
84
|
+
* outcome the caller was already assuming.
|
|
85
|
+
*/
|
|
86
|
+
export declare function residualQueryChecks(query: ElementQuery): ResidualQueryChecks;
|
|
87
|
+
/** Does this element satisfy every field the locator dropped? */
|
|
88
|
+
export declare function satisfiesResiduals(element: ElementDescriptor, checks: readonly [string, string][]): boolean;
|
|
89
|
+
/** How the element's own reading of a dropped field should be REPORTED back on a failure. */
|
|
90
|
+
export declare function describeResidual(element: ElementDescriptor, field: string): string;
|
|
91
|
+
export declare const PredicateSchema: z.ZodType<Predicate>;
|
|
92
|
+
/**
|
|
93
|
+
* The fields a given predicate kind accepts, read off the schema itself.
|
|
94
|
+
*
|
|
95
|
+
* Derived rather than listed so the two can never disagree: a rejection message that names a stale
|
|
96
|
+
* field set is worse than one that names none, because the agent trusts it and retries into the same
|
|
97
|
+
* wall. Empty for a kind that is not in the union.
|
|
98
|
+
*/
|
|
99
|
+
export declare function predicateFieldsFor(kind: string): readonly string[];
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { ElementQuerySchema, ElementState, PredicateKind, QueryBy, } from '@reticlehq/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* Spellings an agent plausibly reaches for, mapped to the real field.
|
|
5
|
+
*
|
|
6
|
+
* `route` spells its field `pathname` while `state`, in the same union, spells its `path`. Every one
|
|
7
|
+
* of these was silently DROPPED before, and because these kinds have all-optional fields, dropping
|
|
8
|
+
* the only key supplied left a predicate that asserts nothing and passes on anything.
|
|
9
|
+
*/
|
|
10
|
+
const PREDICATE_ALIASES = {
|
|
11
|
+
// `text`/`value` on a `text` predicate: the kind is called "text", so `text:` is the first thing
|
|
12
|
+
// anyone writes for it, and `value` follows from the element query's own `value` field. Both were
|
|
13
|
+
// hard rejections, and a rejected predicate produces NO verdict at all — the drive ends with
|
|
14
|
+
// nothing rather than with a failure, which is the worst outcome of the three.
|
|
15
|
+
[PredicateKind.TEXT]: { text: 'contains', value: 'contains' },
|
|
16
|
+
// `urlContains`/`url` reported from the field: an agent that had just written
|
|
17
|
+
// `net { urlContains }` applied the same word to `route`, which spells it `contains`, and got
|
|
18
|
+
// `unrecognized_keys` with no list of what would have worked. The parallel it assumed is a fair
|
|
19
|
+
// one — route's `contains` matches the WHOLE route (path + query + fragment), so "the URL
|
|
20
|
+
// contains this" is precisely what it does — and asserting a redirect after login is the single
|
|
21
|
+
// most common thing an agent reaches for here.
|
|
22
|
+
[PredicateKind.ROUTE]: { path: 'pathname', urlContains: 'contains', url: 'contains' },
|
|
23
|
+
[PredicateKind.NET]: { url: 'urlContains' },
|
|
24
|
+
[PredicateKind.SIGNAL]: { data: 'dataMatches' },
|
|
25
|
+
// `of` on a composite: it reads naturally, several assertion libraries spell it that way, and it has
|
|
26
|
+
// no other meaning here. Observed twice in one drive on a real app, and each rejection cost a round
|
|
27
|
+
// trip AND produced no verdict — a composite is what an agent reaches for precisely when it has two
|
|
28
|
+
// things to prove at once, so failing it is expensive at the worst moment.
|
|
29
|
+
[PredicateKind.ALL_OF]: { of: 'predicates' },
|
|
30
|
+
[PredicateKind.ANY_OF]: { of: 'predicates' },
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Element-query fields an agent writes FLAT on an `element` predicate instead of nested under
|
|
34
|
+
* `query`. `reticle_query` takes exactly these at the top level, so an agent that has just located
|
|
35
|
+
* something writes the same words again when it asserts on it — and got `query: Required` plus an
|
|
36
|
+
* `unknown field` list for its trouble. Lifting them is unambiguous: they have no other meaning on
|
|
37
|
+
* this kind.
|
|
38
|
+
*/
|
|
39
|
+
const ELEMENT_QUERY_FIELDS = [
|
|
40
|
+
'by',
|
|
41
|
+
'value',
|
|
42
|
+
'role',
|
|
43
|
+
'name',
|
|
44
|
+
'text',
|
|
45
|
+
'label',
|
|
46
|
+
'placeholder',
|
|
47
|
+
'testid',
|
|
48
|
+
'alt',
|
|
49
|
+
'component',
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* The locator fields the browser actually CONSUMES for a given query — mirrors the precedence in
|
|
53
|
+
* `findIn` (packages/browser/src/dom/query.ts).
|
|
54
|
+
*
|
|
55
|
+
* An element query is not a conjunction. It is a first-match dispatch: `by`+`value` wins, then the
|
|
56
|
+
* component/source anchor, then `role` (which alone also consumes `name`), then the first of
|
|
57
|
+
* text/label/placeholder/testid/alt that is present. Every OTHER field the caller wrote is dropped on
|
|
58
|
+
* the floor, silently, and the match reported as if the whole query had been honoured.
|
|
59
|
+
*
|
|
60
|
+
* Duplicated here on purpose. The alternative is to send the question to the browser, and the browser
|
|
61
|
+
* cannot answer it: by the time a match comes back, the fields it ignored are indistinguishable from
|
|
62
|
+
* the fields it used.
|
|
63
|
+
*/
|
|
64
|
+
function usedQueryFields(query) {
|
|
65
|
+
const used = new Set();
|
|
66
|
+
if (query.by !== undefined && query.value !== undefined) {
|
|
67
|
+
used.add('by').add('value');
|
|
68
|
+
if (QueryBy.ROLE === query.by)
|
|
69
|
+
used.add('name');
|
|
70
|
+
if (QueryBy.COMPONENT === query.by)
|
|
71
|
+
used.add('component');
|
|
72
|
+
return used;
|
|
73
|
+
}
|
|
74
|
+
if (query.component !== undefined || query.source !== undefined)
|
|
75
|
+
return used.add('component');
|
|
76
|
+
if (query.role !== undefined)
|
|
77
|
+
return used.add('role').add('name');
|
|
78
|
+
for (const field of ['text', 'label', 'placeholder', 'testid', 'alt']) {
|
|
79
|
+
if (query[field] !== undefined)
|
|
80
|
+
return used.add(field);
|
|
81
|
+
}
|
|
82
|
+
return used;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* How a dropped field is checked back on the server, against the descriptor the match returned.
|
|
86
|
+
*
|
|
87
|
+
* `value` is the field this exists for: `{ role: "textbox", name: "GST amount", value: "274.58" }`
|
|
88
|
+
* read as a locator has no `by`, so the value half was discarded and the predicate collapsed to "a
|
|
89
|
+
* textbox named GST amount exists" — trivially true against an EMPTY field. Comparison is TRIMMED and
|
|
90
|
+
* exact: an input's value is a value, not prose, and a trailing space in either the app or the
|
|
91
|
+
* predicate is not a finding anybody wants. `""` asserts the field is empty, which describe() reports
|
|
92
|
+
* by omitting the field entirely.
|
|
93
|
+
*
|
|
94
|
+
* `role`/`name`/`value` compare against exactly what `reticle_query` REPORTS, so the words an agent
|
|
95
|
+
* copies out of a query result are the words that match here. `text` is a substring match, matching
|
|
96
|
+
* Testing Library's `exact: false`, and falls back to the name because describe() omits `text` when it
|
|
97
|
+
* equals the accessible name.
|
|
98
|
+
*/
|
|
99
|
+
const RESIDUAL_CHECKS = {
|
|
100
|
+
value: (element, want) => (element.value ?? '').trim() === want.trim(),
|
|
101
|
+
role: (element, want) => element.role === want,
|
|
102
|
+
name: (element, want) => element.name.trim() === want.trim(),
|
|
103
|
+
text: (element, want) => (element.text ?? element.name).includes(want),
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
|
|
107
|
+
*
|
|
108
|
+
* The alternative — refuse every dropped field — breaks calls that work today and that our own
|
|
109
|
+
* cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
|
|
110
|
+
* Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
|
|
111
|
+
* outcome the caller was already assuming.
|
|
112
|
+
*/
|
|
113
|
+
export function residualQueryChecks(query) {
|
|
114
|
+
const used = usedQueryFields(query);
|
|
115
|
+
const checks = [];
|
|
116
|
+
const unusable = [];
|
|
117
|
+
for (const field of ELEMENT_QUERY_FIELDS) {
|
|
118
|
+
const want = query[field];
|
|
119
|
+
if (want === undefined || used.has(field))
|
|
120
|
+
continue;
|
|
121
|
+
if ('string' === typeof want && RESIDUAL_CHECKS[field] !== undefined)
|
|
122
|
+
checks.push([field, want]);
|
|
123
|
+
else
|
|
124
|
+
unusable.push(field);
|
|
125
|
+
}
|
|
126
|
+
return { checks, unusable };
|
|
127
|
+
}
|
|
128
|
+
/** Does this element satisfy every field the locator dropped? */
|
|
129
|
+
export function satisfiesResiduals(element, checks) {
|
|
130
|
+
return checks.every(([field, want]) => true === RESIDUAL_CHECKS[field]?.(element, want));
|
|
131
|
+
}
|
|
132
|
+
/** How the element's own reading of a dropped field should be REPORTED back on a failure. */
|
|
133
|
+
export function describeResidual(element, field) {
|
|
134
|
+
const reading = 'value' === field
|
|
135
|
+
? (element.value ?? '')
|
|
136
|
+
: 'text' === field
|
|
137
|
+
? (element.text ?? element.name)
|
|
138
|
+
: 'role' === field
|
|
139
|
+
? element.role
|
|
140
|
+
: element.name;
|
|
141
|
+
return `${element.role} "${element.name}" ${field}=${JSON.stringify(reading)}`;
|
|
142
|
+
}
|
|
143
|
+
/** Rename known aliases before parse; an explicit canonical key always wins. */
|
|
144
|
+
function applyPredicateAliases(input) {
|
|
145
|
+
if (typeof input !== 'object' || null === input || Array.isArray(input))
|
|
146
|
+
return input;
|
|
147
|
+
const obj = input;
|
|
148
|
+
const kind = 'string' === typeof obj['kind'] ? obj['kind'] : '';
|
|
149
|
+
const aliases = PREDICATE_ALIASES[kind];
|
|
150
|
+
let out = obj;
|
|
151
|
+
if (aliases !== undefined) {
|
|
152
|
+
out = { ...obj };
|
|
153
|
+
for (const [from, to] of Object.entries(aliases)) {
|
|
154
|
+
if (out[from] === undefined)
|
|
155
|
+
continue;
|
|
156
|
+
if (out[to] === undefined)
|
|
157
|
+
out[to] = out[from];
|
|
158
|
+
delete out[from];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return PredicateKind.ELEMENT === kind ? liftElementQuery(out) : out;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Fold flat query fields into `query`. An explicit `query` wins outright — a caller that supplied
|
|
165
|
+
* both told us which one it meant, and merging the two would invent a locator neither side wrote.
|
|
166
|
+
*/
|
|
167
|
+
function liftElementQuery(obj) {
|
|
168
|
+
const loose = ELEMENT_QUERY_FIELDS.filter((field) => obj[field] !== undefined);
|
|
169
|
+
if (0 === loose.length)
|
|
170
|
+
return obj;
|
|
171
|
+
const out = { ...obj };
|
|
172
|
+
const query = {};
|
|
173
|
+
for (const field of loose) {
|
|
174
|
+
query[field] = out[field];
|
|
175
|
+
delete out[field];
|
|
176
|
+
}
|
|
177
|
+
if (out['query'] === undefined)
|
|
178
|
+
out['query'] = query;
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
|
|
183
|
+
* a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
|
|
184
|
+
*
|
|
185
|
+
* Built on demand rather than at module scope: the `allOf`/`anyOf`/`not` branches reference
|
|
186
|
+
* `PredicateSchema` itself, which does not exist yet while this module is initialising. Calling it
|
|
187
|
+
* after init is what makes the union introspectable — see `predicateFieldsFor`.
|
|
188
|
+
*/
|
|
189
|
+
function predicateUnion() {
|
|
190
|
+
return z.discriminatedUnion('kind', [
|
|
191
|
+
z
|
|
192
|
+
.object({
|
|
193
|
+
kind: z.literal(PredicateKind.ELEMENT),
|
|
194
|
+
query: ElementQuerySchema,
|
|
195
|
+
state: z.nativeEnum(ElementState).optional(),
|
|
196
|
+
absent: z.boolean().optional(),
|
|
197
|
+
})
|
|
198
|
+
.strict(),
|
|
199
|
+
z
|
|
200
|
+
.object({
|
|
201
|
+
kind: z.literal(PredicateKind.TEXT),
|
|
202
|
+
contains: z.string(),
|
|
203
|
+
visible: z.boolean().optional(),
|
|
204
|
+
absent: z.boolean().optional(),
|
|
205
|
+
scope: z.string().optional(),
|
|
206
|
+
})
|
|
207
|
+
.strict(),
|
|
208
|
+
z
|
|
209
|
+
.object({
|
|
210
|
+
kind: z.literal(PredicateKind.NET),
|
|
211
|
+
method: z.string().optional(),
|
|
212
|
+
urlContains: z.string().optional(),
|
|
213
|
+
status: z.number().optional(),
|
|
214
|
+
ok: z.boolean().optional(),
|
|
215
|
+
since: z.number().optional(),
|
|
216
|
+
count: z.number().int().nonnegative().optional(),
|
|
217
|
+
/**
|
|
218
|
+
* A substring the call's RESPONSE body must contain - what the server answered.
|
|
219
|
+
*
|
|
220
|
+
* The only channel that can catch a UI echoing its own input instead of the server's answer.
|
|
221
|
+
* Reported from a real payments UI: a refund posted `{"amount":"1187.01"}`, the server read it
|
|
222
|
+
* as paise and answered 200 with `{"refunded":11.87}`, and the page displayed the number the
|
|
223
|
+
* user had typed. Request fired, exactly once, status 200, console clean, page settled — every
|
|
224
|
+
* assertable channel green on a hundred-fold wrong refund, so the verdict was `yes`.
|
|
225
|
+
*
|
|
226
|
+
* A substring rather than a JSON path, deliberately: `"refunded":11.87` is the whole assertion
|
|
227
|
+
* for the money case, it needs no schema for the body, and it works the same on JSON, form
|
|
228
|
+
* encoding and plain text. A path-and-equals form can be added later if a real case needs one;
|
|
229
|
+
* this is the shape that turns "a blob I read" into a verdict.
|
|
230
|
+
*
|
|
231
|
+
* Requires body capture (`reticle({ captureNetworkBodies: true })`), and says so when the body
|
|
232
|
+
* was never recorded rather than reporting an ordinary mismatch.
|
|
233
|
+
*/
|
|
234
|
+
bodyContains: z.string().min(1).optional(),
|
|
235
|
+
})
|
|
236
|
+
.strict(),
|
|
237
|
+
z
|
|
238
|
+
.object({
|
|
239
|
+
kind: z.literal(PredicateKind.ROUTE),
|
|
240
|
+
pathname: z.string().optional(),
|
|
241
|
+
contains: z.string().optional(),
|
|
242
|
+
since: z.number().optional(),
|
|
243
|
+
})
|
|
244
|
+
.strict(),
|
|
245
|
+
z
|
|
246
|
+
.object({
|
|
247
|
+
kind: z.literal(PredicateKind.CONSOLE),
|
|
248
|
+
level: z.string().optional(),
|
|
249
|
+
absent: z.boolean().optional(),
|
|
250
|
+
since: z.number().optional(),
|
|
251
|
+
})
|
|
252
|
+
.strict(),
|
|
253
|
+
z
|
|
254
|
+
.object({
|
|
255
|
+
kind: z.literal(PredicateKind.ANIMATION),
|
|
256
|
+
name: z.string().optional(),
|
|
257
|
+
target: z.string().optional(),
|
|
258
|
+
completed: z.boolean().optional(),
|
|
259
|
+
since: z.number().optional(),
|
|
260
|
+
})
|
|
261
|
+
.strict(),
|
|
262
|
+
z
|
|
263
|
+
.object({
|
|
264
|
+
kind: z.literal(PredicateKind.SIGNAL),
|
|
265
|
+
name: z.string().optional(),
|
|
266
|
+
dataMatches: z.record(z.unknown()).optional(),
|
|
267
|
+
since: z.number().optional(),
|
|
268
|
+
})
|
|
269
|
+
.strict(),
|
|
270
|
+
z
|
|
271
|
+
.object({
|
|
272
|
+
kind: z.literal(PredicateKind.STATE),
|
|
273
|
+
store: z.string().optional(),
|
|
274
|
+
path: z.string(),
|
|
275
|
+
equals: z.unknown().optional(),
|
|
276
|
+
})
|
|
277
|
+
.strict(),
|
|
278
|
+
z
|
|
279
|
+
.object({
|
|
280
|
+
kind: z.literal(PredicateKind.SETTLED),
|
|
281
|
+
quietMs: z.number().positive().optional(),
|
|
282
|
+
})
|
|
283
|
+
.strict(),
|
|
284
|
+
z
|
|
285
|
+
.object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
|
|
286
|
+
.strict(),
|
|
287
|
+
z
|
|
288
|
+
.object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
|
|
289
|
+
.strict(),
|
|
290
|
+
z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
|
|
291
|
+
]);
|
|
292
|
+
}
|
|
293
|
+
export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, predicateUnion()));
|
|
294
|
+
/**
|
|
295
|
+
* The fields a given predicate kind accepts, read off the schema itself.
|
|
296
|
+
*
|
|
297
|
+
* Derived rather than listed so the two can never disagree: a rejection message that names a stale
|
|
298
|
+
* field set is worse than one that names none, because the agent trusts it and retries into the same
|
|
299
|
+
* wall. Empty for a kind that is not in the union.
|
|
300
|
+
*/
|
|
301
|
+
export function predicateFieldsFor(kind) {
|
|
302
|
+
for (const option of predicateUnion().options) {
|
|
303
|
+
const literal = option.shape['kind'];
|
|
304
|
+
if (literal instanceof z.ZodLiteral && literal.value === kind) {
|
|
305
|
+
return Object.keys(option.shape).filter((field) => 'kind' !== field);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
@@ -11,6 +11,14 @@ export interface PredicateSession {
|
|
|
11
11
|
onEvent(listener: (event: ReticleEvent) => void): () => void;
|
|
12
12
|
/** Milliseconds since connect — the same clock that stamps event `t` (injected, testable). */
|
|
13
13
|
elapsed(): number;
|
|
14
|
+
/**
|
|
15
|
+
* Where the app is RIGHT NOW — the session's live URL, kept current across SPA navigation.
|
|
16
|
+
*
|
|
17
|
+
* Read by the `route` predicate when the window holds no route change, which is the only way
|
|
18
|
+
* "did the session survive a reload?" can be answered at all. Optional: a fake session that never
|
|
19
|
+
* navigates simply omits it and route falls back to change-only, as before.
|
|
20
|
+
*/
|
|
21
|
+
url?: string;
|
|
14
22
|
/**
|
|
15
23
|
* Learned per-ref ambient-churn counts (real-time regions that churn with no action driving them).
|
|
16
24
|
* The settle oracle drops events on learned-ambient refs so a chat/ticker page can still go quiet.
|
package/dist/events/predicate.js
CHANGED
|
@@ -5,7 +5,8 @@ import { selectPath, capDepth } from '../session/state-select.js';
|
|
|
5
5
|
import { describeTestidMiss } from './testid-near-miss.js';
|
|
6
6
|
import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
|
|
7
7
|
import { isAmbient, ambientKeyOf } from '../journal/ambient.js';
|
|
8
|
-
import {
|
|
8
|
+
import { evalRoute } from './predicate-route.js';
|
|
9
|
+
import { PredicateSchema, matchValue, evalNet, evalConsole, evalAnimation, evalSignal, evalSettled, residualQueryChecks, satisfiesResiduals, describeResidual, } from './predicate-eval.js';
|
|
9
10
|
export { PredicateSchema, PredicateKind };
|
|
10
11
|
/**
|
|
11
12
|
* A composite result that carries a child's "nobody could evaluate this" up to the verdict rule.
|
|
@@ -189,8 +190,107 @@ function asSelection(result) {
|
|
|
189
190
|
? { found: record.found, value: record.value }
|
|
190
191
|
: undefined;
|
|
191
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* A NAMED store needs no whole-store read. `reticle_state`'s scoped mode selects the path out of the
|
|
195
|
+
* RAW store IN-PAGE and returns only it, so the assertion resolves in one round trip against a payload
|
|
196
|
+
* the size of the value — not the store. The unnamed case below still needs the wide read, because
|
|
197
|
+
* that is how it discovers WHICH store carries the path; only the named branch changes.
|
|
198
|
+
*
|
|
199
|
+
* A scoped read answers `found: false` for both "no such store" and "no such path", so the two are
|
|
200
|
+
* kept distinct here using the `storeNames` list the reply always carries — otherwise the payload gets
|
|
201
|
+
* cheaper while the message gets worse.
|
|
202
|
+
*/
|
|
203
|
+
async function evalStateNamed(session, p, storeName) {
|
|
204
|
+
const scoped = await session.command(ReticleCommand.STATE_READ, {
|
|
205
|
+
store: storeName,
|
|
206
|
+
path: p.path,
|
|
207
|
+
});
|
|
208
|
+
if (!scoped.ok) {
|
|
209
|
+
return {
|
|
210
|
+
pass: false,
|
|
211
|
+
failureReason: 'state read failed',
|
|
212
|
+
observed: 'the store could not be read',
|
|
213
|
+
expected: 'a readable registered store',
|
|
214
|
+
assertion: 'state.unreadable',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const reply = (scoped.result ?? {});
|
|
218
|
+
const names = Array.isArray(reply.storeNames) ? reply.storeNames : [];
|
|
219
|
+
// Resolve the value. A CURRENT SDK honours the scoped read and answers `{ found, value }` selected
|
|
220
|
+
// in-page — the whole point of this path. An OLDER SDK (version skew) or any transport that ignores
|
|
221
|
+
// `path` answers the whole-store shape `{ stores }`; walk the path server-side there so the verdict
|
|
222
|
+
// stays correct across SDK versions. The scoped win is simply unavailable on the old ones.
|
|
223
|
+
const scopedSel = asSelection(scoped.result);
|
|
224
|
+
const wholeStores = reply.stores;
|
|
225
|
+
const scopedKeys = Array.isArray(reply.availableKeys)
|
|
226
|
+
? reply.availableKeys
|
|
227
|
+
: undefined;
|
|
228
|
+
let selection;
|
|
229
|
+
if (scopedSel !== undefined) {
|
|
230
|
+
selection =
|
|
231
|
+
scopedKeys === undefined
|
|
232
|
+
? { found: scopedSel.found, value: scopedSel.value }
|
|
233
|
+
: { found: scopedSel.found, value: scopedSel.value, availableKeys: scopedKeys };
|
|
234
|
+
}
|
|
235
|
+
else if (wholeStores !== undefined) {
|
|
236
|
+
selection = selectPath(wholeStores[storeName], p.path);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
selection = { found: false };
|
|
240
|
+
}
|
|
241
|
+
// "no store named X" and "X has no such path" both surface as found:false; keep them distinct using
|
|
242
|
+
// the store list the reply carries, or the message regresses while the payload improves.
|
|
243
|
+
const storeAbsent = wholeStores !== undefined
|
|
244
|
+
? !(storeName in wholeStores)
|
|
245
|
+
: names.length > 0 && !names.includes(storeName);
|
|
246
|
+
if (!selection.found && storeAbsent) {
|
|
247
|
+
return {
|
|
248
|
+
pass: false,
|
|
249
|
+
failureReason: `no store named '${storeName}' is registered (${names.join(', ')})`,
|
|
250
|
+
observed: `store '${storeName}' is not registered`,
|
|
251
|
+
expected: `a registered store named '${storeName}'`,
|
|
252
|
+
assertion: 'state.store-missing',
|
|
253
|
+
evidence: { searchedStores: names },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
// A scoped sub-tree can itself be too big for the caps; a comparison against a value known to be
|
|
257
|
+
// incomplete is an unanswered question, not a failure. Same rule the whole-store path applies.
|
|
258
|
+
if (truncationOf(scoped.result) !== undefined) {
|
|
259
|
+
const reason = `state '${p.path}' could not be read intact — the transport caps truncated it, so the ` +
|
|
260
|
+
'value was never compared. Assert a narrower path, or a smaller field inside it';
|
|
261
|
+
return { pass: false, failureReason: reason, inconclusive: reason };
|
|
262
|
+
}
|
|
263
|
+
if (!selection.found) {
|
|
264
|
+
return {
|
|
265
|
+
pass: false,
|
|
266
|
+
failureReason: `state path '${p.path}' not found in store '${storeName}'`,
|
|
267
|
+
observed: `no path '${p.path}' in store '${storeName}'`,
|
|
268
|
+
expected: `store '${storeName}' to expose '${p.path}'`,
|
|
269
|
+
assertion: 'state.path-missing',
|
|
270
|
+
evidence: { availableKeys: selection.availableKeys },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const want = p.equals === undefined ? '*' : p.equals;
|
|
274
|
+
if (matchValue(selection.value, want)) {
|
|
275
|
+
return {
|
|
276
|
+
pass: true,
|
|
277
|
+
evidence: { store: storeName, path: p.path, value: capDepth(selection.value, 1) },
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
pass: false,
|
|
282
|
+
failureReason: `state '${p.path}' is ${JSON.stringify(capDepth(selection.value, 0))}, expected ${JSON.stringify(want)}`,
|
|
283
|
+
observed: `${p.path} = ${JSON.stringify(capDepth(selection.value, 0))}`,
|
|
284
|
+
expected: `${p.path} = ${JSON.stringify(want)}`,
|
|
285
|
+
assertion: 'state.equals',
|
|
286
|
+
evidence: { store: storeName, path: p.path, value: capDepth(selection.value, 1) },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
192
289
|
async function evalState(session, p) {
|
|
193
|
-
|
|
290
|
+
// Named store: one scoped read, no whole-store payload (issue #336).
|
|
291
|
+
if (p.store !== undefined)
|
|
292
|
+
return await evalStateNamed(session, p, p.store);
|
|
293
|
+
const res = await session.command(ReticleCommand.STATE_READ, {});
|
|
194
294
|
if (!res.ok) {
|
|
195
295
|
return {
|
|
196
296
|
pass: false,
|
|
@@ -316,11 +416,16 @@ diagnose = true) {
|
|
|
316
416
|
case PredicateKind.ELEMENT:
|
|
317
417
|
return evalElement(session, predicate.query, predicate.state, predicate.absent ?? false, diagnose);
|
|
318
418
|
case PredicateKind.TEXT:
|
|
319
|
-
return evalElement(session,
|
|
419
|
+
return evalElement(session,
|
|
420
|
+
// `scope` passes straight through: the text predicate has always been an element query
|
|
421
|
+
// with only `text` filled in, so scoping it needs the field, not a second code path.
|
|
422
|
+
undefined === predicate.scope
|
|
423
|
+
? { text: predicate.contains }
|
|
424
|
+
: { text: predicate.contains, scope: predicate.scope }, true === predicate.visible ? ElementState.VISIBLE : undefined, predicate.absent ?? false, diagnose);
|
|
320
425
|
case PredicateKind.NET:
|
|
321
426
|
return evalNet(events, predicate);
|
|
322
427
|
case PredicateKind.ROUTE:
|
|
323
|
-
return evalRoute(events, predicate);
|
|
428
|
+
return evalRoute(events, predicate, session.url);
|
|
324
429
|
case PredicateKind.CONSOLE:
|
|
325
430
|
return evalConsole(events, predicate);
|
|
326
431
|
case PredicateKind.ANIMATION:
|
|
@@ -348,6 +453,10 @@ diagnose = true) {
|
|
|
348
453
|
return {
|
|
349
454
|
pass: false,
|
|
350
455
|
failureReason: failed.failureReason ?? 'a sub-predicate of allOf failed',
|
|
456
|
+
// A conjunction is decided as soon as ONE clause is: nothing the others do later can
|
|
457
|
+
// rescue it. This is what makes the early exit reach real calls, since an exact count is
|
|
458
|
+
// usually asserted alongside the UI change it is meant to accompany.
|
|
459
|
+
...(true === failed.decided ? { decided: true } : {}),
|
|
351
460
|
evidence: results,
|
|
352
461
|
};
|
|
353
462
|
}
|
|
@@ -376,9 +485,14 @@ diagnose = true) {
|
|
|
376
485
|
// You cannot negate an answer nobody had.
|
|
377
486
|
if (inner.inconclusive !== undefined)
|
|
378
487
|
return unreadableComposite(inner, inner);
|
|
488
|
+
// A green negation used to return a bare `{ pass: true }`, so the payload fell back to
|
|
489
|
+
// whatever the caller had — every element matching the OUTER locator. Three clauses negating
|
|
490
|
+
// three different names then produced byte-identical responses, which reads as a checker that
|
|
491
|
+
// dropped the name rather than one that correctly found all three absent. The evidence is the
|
|
492
|
+
// whole answer here: what was looked for, and that it was not there.
|
|
379
493
|
return inner.pass
|
|
380
494
|
? { pass: false, failureReason: 'negated predicate unexpectedly held', evidence: inner }
|
|
381
|
-
: { pass: true };
|
|
495
|
+
: { pass: true, evidence: { negated: predicate.predicate, held: false, saw: inner } };
|
|
382
496
|
}
|
|
383
497
|
default:
|
|
384
498
|
return { pass: false, failureReason: 'unknown predicate' };
|
|
@@ -494,6 +608,14 @@ export function waitForPredicate(session, predicate, timeoutMs, since = 0) {
|
|
|
494
608
|
void evaluatePredicate(session, predicate, since, false)
|
|
495
609
|
.then((r) => {
|
|
496
610
|
if (!r.pass) {
|
|
611
|
+
// Final already: stop rather than spend a budget that cannot change the answer. Only
|
|
612
|
+
// where the evaluator could PROVE it (see EvalResult.decided) — an ordinary miss keeps
|
|
613
|
+
// waiting, because "it has not happened yet" and "it will not happen" are the same
|
|
614
|
+
// reading until the budget ends.
|
|
615
|
+
if (true === r.decided) {
|
|
616
|
+
finish(r);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
497
619
|
// A time-based failure knows when it could stop being one — re-check THEN rather than on
|
|
498
620
|
// the next blind tick. Without this, every `settled` wait paid up to a full poll interval
|
|
499
621
|
// of dead time after the quiet window had already closed: measured at 566–627ms across
|
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
export declare function findStaleResponses(events: readonly ReticleEvent[]): Contradiction[];
|
|
2
|
+
import type { OwnContradiction } from './contradictions.js';
|
|
3
|
+
export declare function findStaleResponses(events: readonly ReticleEvent[]): OwnContradiction[];
|