@runtypelabs/persona 1.47.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -8
- package/dist/index.cjs +90 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1093 -25
- package/dist/index.d.ts +1093 -25
- package/dist/index.global.js +111 -60
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +90 -39
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/widget.css +852 -505
- package/package.json +1 -1
- package/src/artifacts-session.test.ts +80 -0
- package/src/client.test.ts +20 -21
- package/src/client.ts +153 -4
- package/src/components/approval-bubble.ts +45 -42
- package/src/components/artifact-card.ts +91 -0
- package/src/components/artifact-pane.ts +501 -0
- package/src/components/composer-builder.ts +32 -27
- package/src/components/event-stream-view.ts +40 -40
- package/src/components/feedback.ts +36 -36
- package/src/components/forms.ts +11 -11
- package/src/components/header-builder.test.ts +32 -0
- package/src/components/header-builder.ts +55 -36
- package/src/components/header-layouts.ts +58 -125
- package/src/components/launcher.ts +36 -21
- package/src/components/message-bubble.ts +92 -65
- package/src/components/messages.ts +2 -2
- package/src/components/panel.ts +42 -11
- package/src/components/reasoning-bubble.ts +23 -23
- package/src/components/registry.ts +4 -0
- package/src/components/suggestions.ts +1 -1
- package/src/components/tool-bubble.ts +32 -32
- package/src/defaults.ts +30 -4
- package/src/index.ts +80 -2
- package/src/install.ts +22 -0
- package/src/plugins/types.ts +23 -0
- package/src/postprocessors.ts +2 -2
- package/src/runtime/host-layout.ts +174 -0
- package/src/runtime/init.test.ts +236 -0
- package/src/runtime/init.ts +114 -55
- package/src/session.ts +173 -7
- package/src/styles/tailwind.css +1 -1
- package/src/styles/widget.css +852 -505
- package/src/types/theme.ts +354 -0
- package/src/types.ts +348 -16
- package/src/ui.docked.test.ts +104 -0
- package/src/ui.ts +1093 -244
- package/src/utils/artifact-gate.test.ts +255 -0
- package/src/utils/artifact-gate.ts +142 -0
- package/src/utils/artifact-resize.test.ts +64 -0
- package/src/utils/artifact-resize.ts +67 -0
- package/src/utils/attachment-manager.ts +10 -10
- package/src/utils/code-generators.test.ts +52 -0
- package/src/utils/code-generators.ts +40 -36
- package/src/utils/dock.ts +17 -0
- package/src/utils/dom-context.test.ts +504 -0
- package/src/utils/dom-context.ts +896 -0
- package/src/utils/dom.ts +12 -1
- package/src/utils/message-fingerprint.test.ts +187 -0
- package/src/utils/message-fingerprint.ts +105 -0
- package/src/utils/migration.ts +179 -0
- package/src/utils/morph.ts +1 -1
- package/src/utils/plugins.ts +175 -0
- package/src/utils/positioning.ts +4 -4
- package/src/utils/theme.test.ts +125 -0
- package/src/utils/theme.ts +216 -60
- package/src/utils/tokens.ts +682 -0
- package/src/voice/audio-playback-manager.ts +187 -0
- package/src/voice/runtype-voice-provider.ts +305 -69
- package/src/voice/voice-activity-detector.ts +90 -0
- package/src/voice/voice.test.ts +6 -5
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
collectEnrichedPageContext,
|
|
5
|
+
formatEnrichedContext,
|
|
6
|
+
generateStableSelector,
|
|
7
|
+
defaultParseRules,
|
|
8
|
+
type EnrichedPageElement,
|
|
9
|
+
} from "./dom-context";
|
|
10
|
+
|
|
11
|
+
describe("collectEnrichedPageContext", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
document.body.innerHTML = "";
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("collects basic elements with text", () => {
|
|
21
|
+
document.body.innerHTML = `<div class="product-title">Sourdough Loaf</div>`;
|
|
22
|
+
const result = collectEnrichedPageContext();
|
|
23
|
+
// Should find at least the div (body may also be collected)
|
|
24
|
+
const div = result.find((el) => el.tagName === "div");
|
|
25
|
+
expect(div).toBeDefined();
|
|
26
|
+
expect(div!.text).toContain("Sourdough Loaf");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("classifies buttons as clickable", () => {
|
|
30
|
+
document.body.innerHTML = `<button id="add-btn">Add to Cart</button>`;
|
|
31
|
+
const result = collectEnrichedPageContext();
|
|
32
|
+
const btn = result.find((el) => el.tagName === "button");
|
|
33
|
+
expect(btn).toBeDefined();
|
|
34
|
+
expect(btn!.interactivity).toBe("clickable");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("classifies links with href as navigable", () => {
|
|
38
|
+
document.body.innerHTML = `<a href="/products">Products</a>`;
|
|
39
|
+
const result = collectEnrichedPageContext();
|
|
40
|
+
const link = result.find((el) => el.tagName === "a");
|
|
41
|
+
expect(link).toBeDefined();
|
|
42
|
+
expect(link!.interactivity).toBe("navigable");
|
|
43
|
+
expect(link!.attributes.href).toBe("/products");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("classifies inputs as input", () => {
|
|
47
|
+
document.body.innerHTML = `<input id="qty" type="number" name="quantity" />`;
|
|
48
|
+
const result = collectEnrichedPageContext();
|
|
49
|
+
const input = result.find((el) => el.tagName === "input");
|
|
50
|
+
expect(input).toBeDefined();
|
|
51
|
+
expect(input!.interactivity).toBe("input");
|
|
52
|
+
expect(input!.attributes.type).toBe("number");
|
|
53
|
+
expect(input!.attributes.name).toBe("quantity");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("classifies role=button as clickable", () => {
|
|
57
|
+
document.body.innerHTML = `<div role="button">Click me</div>`;
|
|
58
|
+
const result = collectEnrichedPageContext();
|
|
59
|
+
const btn = result.find(
|
|
60
|
+
(el) => el.tagName === "div" && el.role === "button"
|
|
61
|
+
);
|
|
62
|
+
expect(btn).toBeDefined();
|
|
63
|
+
expect(btn!.interactivity).toBe("clickable");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("classifies static elements", () => {
|
|
67
|
+
document.body.innerHTML = `<p class="description">A fine loaf of bread</p>`;
|
|
68
|
+
const result = collectEnrichedPageContext();
|
|
69
|
+
const p = result.find((el) => el.tagName === "p");
|
|
70
|
+
expect(p).toBeDefined();
|
|
71
|
+
expect(p!.interactivity).toBe("static");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("excludes elements inside the widget host", () => {
|
|
75
|
+
document.body.innerHTML = `
|
|
76
|
+
<div class="persona-host"><button>Widget Button</button></div>
|
|
77
|
+
<button id="real-btn">Real Button</button>
|
|
78
|
+
`;
|
|
79
|
+
const result = collectEnrichedPageContext();
|
|
80
|
+
const widgetBtn = result.find(
|
|
81
|
+
(el) => el.text === "Widget Button"
|
|
82
|
+
);
|
|
83
|
+
expect(widgetBtn).toBeUndefined();
|
|
84
|
+
const realBtn = result.find((el) => el.text === "Real Button");
|
|
85
|
+
expect(realBtn).toBeDefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("excludes script, style, and svg elements", () => {
|
|
89
|
+
document.body.innerHTML = `
|
|
90
|
+
<script>console.log("hi")</script>
|
|
91
|
+
<style>.foo { color: red }</style>
|
|
92
|
+
<svg><path d="M0 0"/></svg>
|
|
93
|
+
<div id="content">Visible content</div>
|
|
94
|
+
`;
|
|
95
|
+
const result = collectEnrichedPageContext();
|
|
96
|
+
expect(result.find((el) => el.tagName === "script")).toBeUndefined();
|
|
97
|
+
expect(result.find((el) => el.tagName === "style")).toBeUndefined();
|
|
98
|
+
expect(result.find((el) => el.tagName === "svg")).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("respects maxElements limit", () => {
|
|
102
|
+
const html = Array.from(
|
|
103
|
+
{ length: 100 },
|
|
104
|
+
(_, i) => `<div class="item-${i}">Item ${i}</div>`
|
|
105
|
+
).join("");
|
|
106
|
+
document.body.innerHTML = html;
|
|
107
|
+
const result = collectEnrichedPageContext({ maxElements: 10 });
|
|
108
|
+
expect(result.length).toBeLessThanOrEqual(10);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("truncates text to maxTextLength", () => {
|
|
112
|
+
const longText = "A".repeat(500);
|
|
113
|
+
document.body.innerHTML = `<div id="long">${longText}</div>`;
|
|
114
|
+
const result = collectEnrichedPageContext({ maxTextLength: 50 });
|
|
115
|
+
const div = result.find(
|
|
116
|
+
(el) => el.tagName === "div" && el.attributes.id === "long"
|
|
117
|
+
);
|
|
118
|
+
expect(div).toBeDefined();
|
|
119
|
+
expect(div!.text.length).toBeLessThanOrEqual(50);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("collects data-* attributes", () => {
|
|
123
|
+
document.body.innerHTML = `<button data-product="sourdough" data-price="1200">Add</button>`;
|
|
124
|
+
const result = collectEnrichedPageContext();
|
|
125
|
+
const btn = result.find((el) => el.tagName === "button");
|
|
126
|
+
expect(btn).toBeDefined();
|
|
127
|
+
expect(btn!.attributes["data-product"]).toBe("sourdough");
|
|
128
|
+
expect(btn!.attributes["data-price"]).toBe("1200");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("collects aria-label", () => {
|
|
132
|
+
document.body.innerHTML = `<button aria-label="Close dialog">X</button>`;
|
|
133
|
+
const result = collectEnrichedPageContext();
|
|
134
|
+
const btn = result.find((el) => el.tagName === "button");
|
|
135
|
+
expect(btn).toBeDefined();
|
|
136
|
+
expect(btn!.attributes["aria-label"]).toBe("Close dialog");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("deduplicates elements that produce the same selector", () => {
|
|
140
|
+
// Two divs with duplicate IDs won't use #id (not unique), so they'll
|
|
141
|
+
// fall through to tag-based selectors which may disambiguate.
|
|
142
|
+
// Test with truly identical elements that produce the same selector:
|
|
143
|
+
document.body.innerHTML = `<div id="only-one">Text</div>`;
|
|
144
|
+
const result = collectEnrichedPageContext();
|
|
145
|
+
const divs = result.filter((el) => el.selector === "#only-one");
|
|
146
|
+
expect(divs.length).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("sorts interactive elements before static ones", () => {
|
|
150
|
+
document.body.innerHTML = `
|
|
151
|
+
<p class="text-content">Static text</p>
|
|
152
|
+
<button id="action-btn">Click</button>
|
|
153
|
+
`;
|
|
154
|
+
const result = collectEnrichedPageContext();
|
|
155
|
+
const btnIdx = result.findIndex((el) => el.tagName === "button");
|
|
156
|
+
const pIdx = result.findIndex(
|
|
157
|
+
(el) => el.tagName === "p" && el.interactivity === "static"
|
|
158
|
+
);
|
|
159
|
+
if (btnIdx >= 0 && pIdx >= 0) {
|
|
160
|
+
expect(btnIdx).toBeLessThan(pIdx);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("handles elements with same class but different data attributes", () => {
|
|
165
|
+
document.body.innerHTML = `
|
|
166
|
+
<button data-product="bread" class="add-btn">Add Bread</button>
|
|
167
|
+
<button data-product="cake" class="add-btn">Add Cake</button>
|
|
168
|
+
`;
|
|
169
|
+
const result = collectEnrichedPageContext();
|
|
170
|
+
const breadBtn = result.find(
|
|
171
|
+
(el) => el.attributes["data-product"] === "bread"
|
|
172
|
+
);
|
|
173
|
+
const cakeBtn = result.find(
|
|
174
|
+
(el) => el.attributes["data-product"] === "cake"
|
|
175
|
+
);
|
|
176
|
+
expect(breadBtn).toBeDefined();
|
|
177
|
+
expect(cakeBtn).toBeDefined();
|
|
178
|
+
expect(breadBtn!.selector).not.toBe(cakeBtn!.selector);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("handles empty body", () => {
|
|
182
|
+
document.body.innerHTML = "";
|
|
183
|
+
const result = collectEnrichedPageContext();
|
|
184
|
+
// May return body itself or empty — either is valid
|
|
185
|
+
expect(Array.isArray(result)).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("handles custom excludeSelector", () => {
|
|
189
|
+
document.body.innerHTML = `
|
|
190
|
+
<div class="my-widget"><button>Widget Btn</button></div>
|
|
191
|
+
<button id="outside">Outside</button>
|
|
192
|
+
`;
|
|
193
|
+
const result = collectEnrichedPageContext({
|
|
194
|
+
excludeSelector: ".my-widget",
|
|
195
|
+
});
|
|
196
|
+
expect(result.find((el) => el.text === "Widget Btn")).toBeUndefined();
|
|
197
|
+
expect(result.find((el) => el.text === "Outside")).toBeDefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("handles select and textarea as input interactivity", () => {
|
|
201
|
+
document.body.innerHTML = `
|
|
202
|
+
<select id="color"><option>Red</option></select>
|
|
203
|
+
<textarea id="notes">Notes here</textarea>
|
|
204
|
+
`;
|
|
205
|
+
const result = collectEnrichedPageContext();
|
|
206
|
+
const sel = result.find((el) => el.tagName === "select");
|
|
207
|
+
const ta = result.find((el) => el.tagName === "textarea");
|
|
208
|
+
expect(sel?.interactivity).toBe("input");
|
|
209
|
+
expect(ta?.interactivity).toBe("input");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("prioritizes product card over generic static when maxElements is tight", () => {
|
|
213
|
+
const filler = Array.from(
|
|
214
|
+
{ length: 40 },
|
|
215
|
+
(_, i) => `<div class="noise-${i}">Noise paragraph ${i} with some text</div>`
|
|
216
|
+
).join("");
|
|
217
|
+
document.body.innerHTML = `
|
|
218
|
+
${filler}
|
|
219
|
+
<div class="product-card" data-product="shirt">
|
|
220
|
+
<a href="/p/shirt">Black Shirt</a>
|
|
221
|
+
<span class="product-price">$29.99</span>
|
|
222
|
+
<button type="button">Add to Cart</button>
|
|
223
|
+
</div>
|
|
224
|
+
`;
|
|
225
|
+
const result = collectEnrichedPageContext({
|
|
226
|
+
options: { maxElements: 8, maxCandidates: 200 },
|
|
227
|
+
});
|
|
228
|
+
const card = result.find((el) => el.attributes["data-product"] === "shirt");
|
|
229
|
+
expect(card).toBeDefined();
|
|
230
|
+
expect(card!.formattedSummary).toBeDefined();
|
|
231
|
+
expect(card!.formattedSummary).toContain("Black Shirt");
|
|
232
|
+
expect(card!.formattedSummary).toContain("$29.99");
|
|
233
|
+
expect(card!.formattedSummary).toContain("/p/shirt");
|
|
234
|
+
const noise = result.filter((el) => el.text.startsWith("Noise paragraph"));
|
|
235
|
+
expect(noise.length).toBeLessThan(8);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("bumps card-like containers with currency + link", () => {
|
|
239
|
+
document.body.innerHTML = `
|
|
240
|
+
<div class="item-tile">
|
|
241
|
+
<h3><a href="/listing/1">Cabin</a></h3>
|
|
242
|
+
<p>Nightly rate $120.00</p>
|
|
243
|
+
</div>
|
|
244
|
+
`;
|
|
245
|
+
const result = collectEnrichedPageContext();
|
|
246
|
+
const tile = result.find((el) => el.tagName === "div" && el.text.includes("Cabin"));
|
|
247
|
+
expect(tile?.formattedSummary).toBeDefined();
|
|
248
|
+
expect(tile!.formattedSummary).toContain("Cabin");
|
|
249
|
+
expect(tile!.formattedSummary).toMatch(/\$120/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("omits redundant price static inside a kept commerce card", () => {
|
|
253
|
+
document.body.innerHTML = `
|
|
254
|
+
<div class="product-card">
|
|
255
|
+
<a href="/x">Widget</a>
|
|
256
|
+
<span class="product-price">$9.00</span>
|
|
257
|
+
<button>Buy</button>
|
|
258
|
+
</div>
|
|
259
|
+
`;
|
|
260
|
+
const result = collectEnrichedPageContext({ options: { maxElements: 20 } });
|
|
261
|
+
const priceOnly = result.filter(
|
|
262
|
+
(el) => el.text.trim() === "$9.00" && el.interactivity === "static"
|
|
263
|
+
);
|
|
264
|
+
expect(priceOnly.length).toBe(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("keeps interactive-first ordering on pages without card rules", () => {
|
|
268
|
+
document.body.innerHTML = `
|
|
269
|
+
<p class="intro">Welcome to our site</p>
|
|
270
|
+
<button id="go">Go</button>
|
|
271
|
+
`;
|
|
272
|
+
const result = collectEnrichedPageContext();
|
|
273
|
+
const btnIdx = result.findIndex((el) => el.tagName === "button");
|
|
274
|
+
const pIdx = result.findIndex(
|
|
275
|
+
(el) => el.tagName === "p" && el.text.includes("Welcome")
|
|
276
|
+
);
|
|
277
|
+
if (btnIdx >= 0 && pIdx >= 0) {
|
|
278
|
+
expect(btnIdx).toBeLessThan(pIdx);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("applies generic result-card rule without currency", () => {
|
|
283
|
+
document.body.innerHTML = `
|
|
284
|
+
<div class="search-result">
|
|
285
|
+
<h2><a href="/doc/setup">Setup guide</a></h2>
|
|
286
|
+
<p class="snippet">Install the CLI and run the init command to get started.</p>
|
|
287
|
+
</div>
|
|
288
|
+
`;
|
|
289
|
+
const result = collectEnrichedPageContext();
|
|
290
|
+
const row = result.find((el) =>
|
|
291
|
+
el.formattedSummary?.includes("Setup guide")
|
|
292
|
+
);
|
|
293
|
+
expect(row?.formattedSummary).toBeDefined();
|
|
294
|
+
expect(row!.formattedSummary).toContain("/doc/setup");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("simple mode ignores custom rules with a warning", () => {
|
|
298
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
299
|
+
document.body.innerHTML = `<button>A</button>`;
|
|
300
|
+
const structured = collectEnrichedPageContext({
|
|
301
|
+
rules: defaultParseRules,
|
|
302
|
+
options: { mode: "structured", maxElements: 5 },
|
|
303
|
+
});
|
|
304
|
+
const simple = collectEnrichedPageContext({
|
|
305
|
+
rules: defaultParseRules,
|
|
306
|
+
options: { mode: "simple", maxElements: 5 },
|
|
307
|
+
});
|
|
308
|
+
expect(warn).toHaveBeenCalled();
|
|
309
|
+
warn.mockRestore();
|
|
310
|
+
expect(simple.every((el) => !el.formattedSummary)).toBe(true);
|
|
311
|
+
expect(structured.length).toBe(simple.length);
|
|
312
|
+
expect(structured[0]?.selector).toBe(simple[0]?.selector);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("generateStableSelector", () => {
|
|
317
|
+
beforeEach(() => {
|
|
318
|
+
document.body.innerHTML = "";
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
afterEach(() => {
|
|
322
|
+
document.body.innerHTML = "";
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("prefers #id when unique", () => {
|
|
326
|
+
document.body.innerHTML = `<button id="add-cart">Add</button>`;
|
|
327
|
+
const el = document.getElementById("add-cart")!;
|
|
328
|
+
expect(generateStableSelector(el)).toBe("#add-cart");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("uses data-testid when id is not unique", () => {
|
|
332
|
+
document.body.innerHTML = `
|
|
333
|
+
<div id="item"><button data-testid="add-sourdough">Add</button></div>
|
|
334
|
+
<div id="item"><button data-testid="add-cake">Add</button></div>
|
|
335
|
+
`;
|
|
336
|
+
const btn = document.querySelector(
|
|
337
|
+
'[data-testid="add-sourdough"]'
|
|
338
|
+
) as HTMLElement;
|
|
339
|
+
const sel = generateStableSelector(btn);
|
|
340
|
+
expect(sel).toContain("data-testid");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("uses data-product attribute", () => {
|
|
344
|
+
document.body.innerHTML = `
|
|
345
|
+
<button data-product="sourdough">Add</button>
|
|
346
|
+
<button data-product="cake">Add</button>
|
|
347
|
+
`;
|
|
348
|
+
const btn = document.querySelector(
|
|
349
|
+
'[data-product="sourdough"]'
|
|
350
|
+
) as HTMLElement;
|
|
351
|
+
const sel = generateStableSelector(btn);
|
|
352
|
+
expect(sel).toContain("data-product");
|
|
353
|
+
expect(sel).toContain("sourdough");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("falls back to tag.class when no id or data attrs", () => {
|
|
357
|
+
document.body.innerHTML = `<button class="primary-action">Go</button>`;
|
|
358
|
+
const btn = document.querySelector("button") as HTMLElement;
|
|
359
|
+
const sel = generateStableSelector(btn);
|
|
360
|
+
expect(sel).toContain("button");
|
|
361
|
+
expect(sel).toContain("primary-action");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("uses nth-of-type for disambiguation", () => {
|
|
365
|
+
document.body.innerHTML = `
|
|
366
|
+
<div class="container">
|
|
367
|
+
<button class="btn">First</button>
|
|
368
|
+
<button class="btn">Second</button>
|
|
369
|
+
</div>
|
|
370
|
+
`;
|
|
371
|
+
const buttons = document.querySelectorAll("button");
|
|
372
|
+
const sel1 = generateStableSelector(buttons[0] as HTMLElement);
|
|
373
|
+
const sel2 = generateStableSelector(buttons[1] as HTMLElement);
|
|
374
|
+
expect(sel1).not.toBe(sel2);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("formatEnrichedContext", () => {
|
|
379
|
+
it("includes structured summaries for formatted elements", () => {
|
|
380
|
+
const elements: EnrichedPageElement[] = [
|
|
381
|
+
{
|
|
382
|
+
selector: "div.card",
|
|
383
|
+
tagName: "div",
|
|
384
|
+
text: "Full card text blob",
|
|
385
|
+
role: null,
|
|
386
|
+
interactivity: "static",
|
|
387
|
+
attributes: {},
|
|
388
|
+
formattedSummary:
|
|
389
|
+
"[Shirt](/p/1) — $10\nselector: div.card\nactions: Add",
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
const out = formatEnrichedContext(elements, { mode: "structured" });
|
|
393
|
+
expect(out).toContain("Structured summaries:");
|
|
394
|
+
expect(out).toContain("[Shirt](/p/1)");
|
|
395
|
+
expect(out).toContain("actions: Add");
|
|
396
|
+
expect(out).not.toContain("Content:");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("ignores formattedSummary in simple mode", () => {
|
|
400
|
+
const elements: EnrichedPageElement[] = [
|
|
401
|
+
{
|
|
402
|
+
selector: "div.card",
|
|
403
|
+
tagName: "div",
|
|
404
|
+
text: "Full card text blob",
|
|
405
|
+
role: null,
|
|
406
|
+
interactivity: "static",
|
|
407
|
+
attributes: {},
|
|
408
|
+
formattedSummary: "should not appear",
|
|
409
|
+
},
|
|
410
|
+
];
|
|
411
|
+
const out = formatEnrichedContext(elements, { mode: "simple" });
|
|
412
|
+
expect(out).not.toContain("Structured summaries:");
|
|
413
|
+
expect(out).toContain("Content:");
|
|
414
|
+
expect(out).toContain("Full card text blob");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("returns message for empty array", () => {
|
|
418
|
+
expect(formatEnrichedContext([])).toBe("No page elements found.");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("groups elements by interactivity", () => {
|
|
422
|
+
const elements: EnrichedPageElement[] = [
|
|
423
|
+
{
|
|
424
|
+
selector: "button#add",
|
|
425
|
+
tagName: "button",
|
|
426
|
+
text: "Add to Cart",
|
|
427
|
+
role: null,
|
|
428
|
+
interactivity: "clickable",
|
|
429
|
+
attributes: { id: "add" },
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
selector: 'a[href="/products"]',
|
|
433
|
+
tagName: "a",
|
|
434
|
+
text: "Products",
|
|
435
|
+
role: null,
|
|
436
|
+
interactivity: "navigable",
|
|
437
|
+
attributes: { href: "/products" },
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
selector: "input#qty",
|
|
441
|
+
tagName: "input",
|
|
442
|
+
text: "",
|
|
443
|
+
role: null,
|
|
444
|
+
interactivity: "input",
|
|
445
|
+
attributes: { type: "number" },
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
selector: "div.title",
|
|
449
|
+
tagName: "div",
|
|
450
|
+
text: "Sourdough Loaf",
|
|
451
|
+
role: null,
|
|
452
|
+
interactivity: "static",
|
|
453
|
+
attributes: {},
|
|
454
|
+
},
|
|
455
|
+
];
|
|
456
|
+
|
|
457
|
+
const result = formatEnrichedContext(elements);
|
|
458
|
+
expect(result).toContain("Interactive elements:");
|
|
459
|
+
expect(result).toContain("Add to Cart");
|
|
460
|
+
expect(result).toContain("(clickable)");
|
|
461
|
+
expect(result).toContain("Navigation links:");
|
|
462
|
+
expect(result).toContain("Products");
|
|
463
|
+
expect(result).toContain("(navigable)");
|
|
464
|
+
expect(result).toContain("Form inputs:");
|
|
465
|
+
expect(result).toContain("(input)");
|
|
466
|
+
expect(result).toContain("Content:");
|
|
467
|
+
expect(result).toContain("Sourdough Loaf");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("omits empty groups", () => {
|
|
471
|
+
const elements = [
|
|
472
|
+
{
|
|
473
|
+
selector: "button#add",
|
|
474
|
+
tagName: "button",
|
|
475
|
+
text: "Click",
|
|
476
|
+
role: null,
|
|
477
|
+
interactivity: "clickable" as const,
|
|
478
|
+
attributes: {},
|
|
479
|
+
},
|
|
480
|
+
];
|
|
481
|
+
const result = formatEnrichedContext(elements);
|
|
482
|
+
expect(result).toContain("Interactive elements:");
|
|
483
|
+
expect(result).not.toContain("Navigation links:");
|
|
484
|
+
expect(result).not.toContain("Form inputs:");
|
|
485
|
+
expect(result).not.toContain("Content:");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("truncates long text in formatted output", () => {
|
|
489
|
+
const elements = [
|
|
490
|
+
{
|
|
491
|
+
selector: "div.long",
|
|
492
|
+
tagName: "div",
|
|
493
|
+
text: "A".repeat(200),
|
|
494
|
+
role: null,
|
|
495
|
+
interactivity: "static" as const,
|
|
496
|
+
attributes: {},
|
|
497
|
+
},
|
|
498
|
+
];
|
|
499
|
+
const result = formatEnrichedContext(elements);
|
|
500
|
+
// Format truncates to 100 chars
|
|
501
|
+
expect(result).toContain("A".repeat(100));
|
|
502
|
+
expect(result).not.toContain("A".repeat(101));
|
|
503
|
+
});
|
|
504
|
+
});
|