@unotest/grounder-client 0.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.
@@ -0,0 +1,182 @@
1
+ import { a as RawCapture, B as Bounds } from './types-CMkcL3q9.js';
2
+ export { b as RawElement, c as RawNode, R as RawState, d as RawText } from './types-CMkcL3q9.js';
3
+
4
+ /** Stable hex content hash of a capture (url + semantic tree). */
5
+ declare function hashRawCapture(raw: RawCapture): string;
6
+
7
+ type Ordinal = {
8
+ kind: "nth";
9
+ n: number;
10
+ } | {
11
+ kind: "last";
12
+ };
13
+ interface PredicateSpec {
14
+ /** Column description in the user's words ("price", "цена") — resolved
15
+ * to an actual column by embedding, multilingually. */
16
+ field: string;
17
+ op: "gt" | "lt" | "eq";
18
+ value: number | string;
19
+ }
20
+ interface Intent {
21
+ /** The full intent text — the semantic channel (embeddings + picker). */
22
+ text: string;
23
+ /** Positional component, typed ("the third row" → { kind: "nth", n: 3 }). */
24
+ ordinal?: Ordinal;
25
+ /** Column predicate ("rows with price > 99") — answered deterministically
26
+ * with a SET of record refs, no picker involved. */
27
+ predicate?: PredicateSpec;
28
+ /** What class of unit the consumer needs back:
29
+ *
30
+ * - `"control"` — an ACTIONABLE element. When a resolution lands on a
31
+ * record (row/item unit — no DOM handle by design), the grounder runs
32
+ * the record→control second hop instead of returning the record: the
33
+ * picker chooses among the record's inner controls. Set by consumers
34
+ * whose targets get clicked/filled (web explore_step click/fill path).
35
+ * - `"unit"` — the semantic unit ITSELF (heading / record / text), even
36
+ * when it is not actionable. Suppresses the record→control hop —
37
+ * including under `ordinal` (the n-th ROW is the answer, not a control
38
+ * inside it). Set by consumers whose targets get READ, not clicked
39
+ * (web assert steps); the consumer materializes a DOM handle from the
40
+ * resolution's nodeId when it needs one.
41
+ * - omitted — like `"unit"` without intent: a record is a valid answer
42
+ * (reading, highlighting), but ordinal intents still hop when the text
43
+ * names a control inside the row. */
44
+ prefer?: "control" | "unit";
45
+ }
46
+ /** A resolved element with everything a consumer needs to act on it:
47
+ * ref for the driver, bounds for screenshot overlays, nodeId for the
48
+ * live-node registry (in-page highlight). */
49
+ interface ResolvedTarget {
50
+ ref: string;
51
+ bounds: Bounds | null;
52
+ nodeId: number | null;
53
+ }
54
+ interface ResolveDiagnostics {
55
+ /** Refs offered to the picker (recall surface), in prompt order. */
56
+ candidateRefs: string[];
57
+ /** Raw picker output (null when the picker wasn't consulted). */
58
+ pickerText: string | null;
59
+ /** Ref parsed from the picker output BEFORE the known-ref guard —
60
+ * differs from the resolution when the picker hallucinated. */
61
+ pickerRef: string | null;
62
+ latencyMs: number;
63
+ pickerLatencyMs?: number;
64
+ /** Ordinal pool size, when an ordinal pool was assembled. */
65
+ poolSize?: number;
66
+ /** Lexical channel hits (intent ∩ typed page values). */
67
+ lexicalHits?: number;
68
+ /** Predicate only: the column the field resolved to. */
69
+ column?: string;
70
+ /** Reranker path: the winning candidate's relevance score. */
71
+ rerankScore?: number;
72
+ /** Name-evidence gate: page strings the intent quoted verbatim, and
73
+ * whether the answer owned one of them. Absent when the intent quotes
74
+ * nothing (gate silent) — see the grounder's name-evidence module. */
75
+ nameEvidence?: {
76
+ phrases: string[];
77
+ carried: boolean;
78
+ };
79
+ /** Typed ordinal resolved deterministically inside a value-filtered
80
+ * subset (e.g. "the last Pending row" over the 48 Pending rows of a
81
+ * 295-row table): the matched value, the subset size and the 0-based
82
+ * index taken. Absent when the picker decided the position. */
83
+ ordinalSubset?: {
84
+ value: string;
85
+ size: number;
86
+ index: number;
87
+ };
88
+ /** Ordinal record→control second hop (when the intent names a control
89
+ * inside the row the ordinal picked): raw hop picker output + parsed
90
+ * ref, plus the exact user message the hop picker saw (debug surface —
91
+ * the eval console prints it verbatim). Absent when no hop ran. */
92
+ recordHop?: {
93
+ pickerText: string | null;
94
+ ref: string | null;
95
+ prompt?: string;
96
+ };
97
+ }
98
+ type Resolution = {
99
+ kind: "ref";
100
+ target: ResolvedTarget;
101
+ diagnostics: ResolveDiagnostics;
102
+ } | {
103
+ kind: "set";
104
+ targets: ResolvedTarget[];
105
+ diagnostics: ResolveDiagnostics;
106
+ } | {
107
+ kind: "none";
108
+ diagnostics: ResolveDiagnostics;
109
+ };
110
+
111
+ interface GroundLines {
112
+ /** pickerLine for every candidate ref, in prompt order. */
113
+ candidateLines: string[];
114
+ /** pickerLine for the resolved target(s): ref → 1, set → up to 10, none → []. */
115
+ targetLines: string[];
116
+ }
117
+
118
+ /** Header carrying the client-chosen session id — the index isolation key. */
119
+ declare const SESSION_HEADER = "x-unotest-session";
120
+ interface IndexRequest {
121
+ rawCapture: RawCapture;
122
+ /** Client-computed content hash — the per-session index key. Lets one
123
+ * session hold several live indexes (page + iframe, LRU-capped). */
124
+ contentHash: string;
125
+ }
126
+ interface IndexResponse {
127
+ units: number;
128
+ indexMs: number;
129
+ }
130
+ interface ResolveRequest {
131
+ intent: Intent;
132
+ /** Which of the session's indexes to resolve against. Unknown hash →
133
+ * 409 `no-index` (evicted or never sent) — client re-sends /index. */
134
+ contentHash: string;
135
+ }
136
+ interface ResolveResponse extends GroundLines {
137
+ resolution: Resolution;
138
+ }
139
+ interface ErrorResponse {
140
+ error: string;
141
+ /** Machine code for control flow. `no-index` → client must re-`/index`. */
142
+ code?: "no-index" | "unauthorized" | "bad-request" | "internal";
143
+ }
144
+ declare const ROUTES: {
145
+ readonly index: "/index";
146
+ readonly resolve: "/resolve";
147
+ readonly close: "/session/close";
148
+ readonly health: "/health";
149
+ };
150
+
151
+ declare class GrounderRemoteError extends Error {
152
+ readonly status: number;
153
+ readonly code: ErrorResponse["code"] | undefined;
154
+ constructor(message: string, status: number, code: ErrorResponse["code"] | undefined);
155
+ }
156
+ interface GrounderHttpClientOptions {
157
+ baseUrl: string;
158
+ token: string;
159
+ /** Override the generated session id (tests). */
160
+ sessionId?: string;
161
+ /** Injected fetch (tests). Defaults to global fetch. */
162
+ fetchImpl?: typeof fetch;
163
+ }
164
+ declare class GrounderHttpClient {
165
+ readonly sessionId: string;
166
+ private readonly baseUrl;
167
+ private readonly token;
168
+ private readonly fetchImpl;
169
+ constructor(opts: GrounderHttpClientOptions);
170
+ index(rawCapture: RawCapture, contentHash: string): Promise<IndexResponse>;
171
+ resolve(intent: Intent, contentHash: string): Promise<ResolveResponse>;
172
+ close(): Promise<void>;
173
+ private post;
174
+ }
175
+
176
+ /** Parse an integer env var with range validation; unset/empty → default.
177
+ * Shared by the consumer side (web perception env) and the grounder server
178
+ * env so the `UNOTEST_GROUNDER_*` parsing rules stay identical on both
179
+ * sides of the service boundary. */
180
+ declare function intEnvInRange(env: NodeJS.ProcessEnv, name: string, def: number, min: number, max: number): number;
181
+
182
+ export { Bounds, type ErrorResponse, type GroundLines, GrounderHttpClient, type GrounderHttpClientOptions, GrounderRemoteError, type IndexRequest, type IndexResponse, type Intent, type Ordinal, type PredicateSpec, ROUTES, RawCapture, type Resolution, type ResolveDiagnostics, type ResolveRequest, type ResolveResponse, type ResolvedTarget, SESSION_HEADER, hashRawCapture, intEnvInRange };
package/dist/index.js ADDED
@@ -0,0 +1,124 @@
1
+ // src/hash.ts
2
+ var FNV_OFFSET = 0xcbf29ce484222325n;
3
+ var FNV_PRIME = 0x100000001b3n;
4
+ var MASK64 = 0xffffffffffffffffn;
5
+ function fnv1a(prev, s) {
6
+ let h = prev;
7
+ for (let i = 0; i < s.length; i++) {
8
+ h = (h ^ BigInt(s.charCodeAt(i))) * FNV_PRIME & MASK64;
9
+ }
10
+ return h;
11
+ }
12
+ function feedNode(h, node) {
13
+ if (node.kind === "text") {
14
+ return fnv1a(h, "t" + node.text);
15
+ }
16
+ const el = node;
17
+ const state = el.state ? [
18
+ el.state.disabled ? "d" : "",
19
+ el.state.checked ? `c${el.state.checked}` : "",
20
+ el.state.pressed ? `p${el.state.pressed}` : "",
21
+ el.state.expanded ? "x" : "",
22
+ el.state.selected ? "s" : "",
23
+ el.state.active ? "a" : "",
24
+ el.state.level !== void 0 ? `l${el.state.level}` : ""
25
+ ].join("") : "";
26
+ const parts = "e" + el.tag + "" + (el.role ?? "") + "" + el.name + "" + (el.value ?? "") + "" + state + "" + (el.ref ?? "") + "" + (el.testId ?? "") + "" + (el.dataId ? el.dataId.name + "=" + el.dataId.value : "") + "" + (el.href ?? "");
27
+ h = fnv1a(h, parts);
28
+ for (const child of el.children) h = feedNode(h, child);
29
+ return fnv1a(h, "/");
30
+ }
31
+ function hashRawCapture(raw) {
32
+ let h = FNV_OFFSET;
33
+ h = fnv1a(h, raw.url);
34
+ h = feedNode(h, raw.tree);
35
+ return h.toString(16).padStart(16, "0");
36
+ }
37
+
38
+ // src/protocol.ts
39
+ var SESSION_HEADER = "x-unotest-session";
40
+ var ROUTES = {
41
+ index: "/index",
42
+ resolve: "/resolve",
43
+ close: "/session/close",
44
+ health: "/health"
45
+ };
46
+
47
+ // src/http-client.ts
48
+ import { randomUUID } from "crypto";
49
+ var GrounderRemoteError = class extends Error {
50
+ constructor(message, status, code) {
51
+ super(message);
52
+ this.status = status;
53
+ this.code = code;
54
+ this.name = "GrounderRemoteError";
55
+ }
56
+ status;
57
+ code;
58
+ };
59
+ var GrounderHttpClient = class {
60
+ sessionId;
61
+ baseUrl;
62
+ token;
63
+ fetchImpl;
64
+ constructor(opts) {
65
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
66
+ this.token = opts.token;
67
+ this.sessionId = opts.sessionId ?? randomUUID();
68
+ this.fetchImpl = opts.fetchImpl ?? fetch;
69
+ }
70
+ index(rawCapture, contentHash) {
71
+ return this.post(ROUTES.index, { rawCapture, contentHash });
72
+ }
73
+ resolve(intent, contentHash) {
74
+ return this.post(ROUTES.resolve, { intent, contentHash });
75
+ }
76
+ async close() {
77
+ await this.post(ROUTES.close, {});
78
+ }
79
+ async post(path, body) {
80
+ let res;
81
+ try {
82
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
83
+ method: "POST",
84
+ headers: {
85
+ "content-type": "application/json",
86
+ authorization: `Bearer ${this.token}`,
87
+ [SESSION_HEADER]: this.sessionId
88
+ },
89
+ body: JSON.stringify(body)
90
+ });
91
+ } catch (e) {
92
+ throw new GrounderRemoteError(
93
+ `cannot reach grounder server at ${this.baseUrl} (${e instanceof Error ? e.message : String(e)})`,
94
+ 0,
95
+ void 0
96
+ );
97
+ }
98
+ const json = await res.json().catch(() => ({}));
99
+ if (!res.ok) {
100
+ throw new GrounderRemoteError(json.error ?? `${path} \u2192 ${res.status}`, res.status, json.code);
101
+ }
102
+ return json;
103
+ }
104
+ };
105
+
106
+ // src/env-util.ts
107
+ function intEnvInRange(env, name, def, min, max) {
108
+ const raw = env[name];
109
+ if (raw === void 0 || raw === "") return def;
110
+ const n = Number.parseInt(raw, 10);
111
+ if (Number.isNaN(n) || n < min || n > max) {
112
+ throw new Error(`${name} must be an integer in [${min}, ${max}], got "${raw}"`);
113
+ }
114
+ return n;
115
+ }
116
+ export {
117
+ GrounderHttpClient,
118
+ GrounderRemoteError,
119
+ ROUTES,
120
+ SESSION_HEADER,
121
+ hashRawCapture,
122
+ intEnvInRange
123
+ };
124
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,69 @@
1
+ interface Bounds {
2
+ x: number;
3
+ y: number;
4
+ w: number;
5
+ h: number;
6
+ }
7
+ interface RawState {
8
+ disabled?: true;
9
+ checked?: true | "mixed";
10
+ pressed?: true | "mixed";
11
+ expanded?: true;
12
+ selected?: true;
13
+ active?: true;
14
+ level?: number;
15
+ }
16
+ interface RawElement {
17
+ kind: "element";
18
+ tag: string;
19
+ /** ARIA role (explicit or implicit), null for generic containers. */
20
+ role: string | null;
21
+ /** W3C accname — computed only when role is non-null ("" otherwise). */
22
+ name: string;
23
+ /** Control value: input/textarea value, select's selected option label. */
24
+ value?: string;
25
+ state?: RawState;
26
+ /** `eN` — minted by the capture adapter for interactive elements. Non-
27
+ * interactive nodes have no ref; the builder assigns `rN` to record
28
+ * roots later. */
29
+ ref?: string;
30
+ /** Index into the capture-side live-node registry (highlight handle for
31
+ * ANY captured element, including rN record roots that never get a DOM
32
+ * ref attribute). Optional: non-live producers (fixtures) omit it. */
33
+ nodeId?: number;
34
+ testId?: string;
35
+ dataId?: {
36
+ name: string;
37
+ value: string;
38
+ };
39
+ href?: string;
40
+ /** Rendered font weight when it deviates from normal (400) — emphasis
41
+ * inside the element counts, so `<a><b>X</b></a>` reports 700. Absent
42
+ * = ordinary weight. A bold entry among plain siblings is how a flat
43
+ * list shows its top level; see the walker. */
44
+ fontWeight?: number;
45
+ bounds: Bounds;
46
+ children: RawNode[];
47
+ }
48
+ interface RawText {
49
+ kind: "text";
50
+ /** Trimmed, whitespace-collapsed, UNCAPPED — cell values must survive. */
51
+ text: string;
52
+ }
53
+ type RawNode = RawElement | RawText;
54
+ interface RawCapture {
55
+ url: string;
56
+ title: string;
57
+ viewport: {
58
+ width: number;
59
+ height: number;
60
+ };
61
+ tree: RawElement;
62
+ stats: {
63
+ elements: number;
64
+ textNodes: number;
65
+ refs: number;
66
+ };
67
+ }
68
+
69
+ export type { Bounds as B, RawState as R, RawCapture as a, RawElement as b, RawNode as c, RawText as d };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@unotest/grounder-client",
3
+ "version": "0.10.0",
4
+ "description": "Client contract for the unotest semantic UI grounder: typed intent/resolution types, the injectable DOM walker (RawCapture), the content hash, and the HTTP client + wire protocol of the grounding service. The grounding engine itself is a private service; this package is everything a consumer needs to capture a page and talk to it.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "author": "Ivan Volkov <ivan@volkov.io>",
10
+ "homepage": "https://www.npmjs.com/package/@unotest/grounder-client",
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./dom-walker": {
23
+ "types": "./dist/dom-walker/index.d.ts",
24
+ "import": "./dist/dom-walker/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "!dist/**/*.map",
30
+ "CHANGELOG.md",
31
+ "LICENSE",
32
+ "README.md"
33
+ ],
34
+ "devDependencies": {
35
+ "@types/node": "^22.10.0",
36
+ "tsup": "^8.5.1",
37
+ "tsx": "^4.19.2",
38
+ "typescript": "^5.7.2"
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "node --import tsx --test --test-reporter=spec 'src/**/*.test.ts'",
43
+ "compile": "tsup",
44
+ "verify": "pnpm typecheck && pnpm check:no-cyrillic && pnpm test",
45
+ "build": "pnpm verify && pnpm compile && pnpm check:tarball",
46
+ "check:no-cyrillic": "node scripts/check-no-cyrillic.mjs",
47
+ "check:tarball": "node scripts/check-tarball.mjs"
48
+ }
49
+ }