@geometra/mcp 1.18.1
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 +164 -0
- package/dist/__tests__/session-model.test.d.ts +1 -0
- package/dist/__tests__/session-model.test.js +128 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +352 -0
- package/dist/session.d.ts +247 -0
- package/dist/session.js +777 -0
- package/package.json +41 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
let activeSession = null;
|
|
3
|
+
/**
|
|
4
|
+
* Connect to a running Geometra server. Waits for the first frame so that
|
|
5
|
+
* layout/tree state is available immediately after connection.
|
|
6
|
+
*/
|
|
7
|
+
export function connect(url) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
if (activeSession) {
|
|
10
|
+
activeSession.ws.close();
|
|
11
|
+
activeSession = null;
|
|
12
|
+
}
|
|
13
|
+
const ws = new WebSocket(url);
|
|
14
|
+
const session = { ws, layout: null, tree: null, url };
|
|
15
|
+
let resolved = false;
|
|
16
|
+
const timeout = setTimeout(() => {
|
|
17
|
+
if (!resolved) {
|
|
18
|
+
resolved = true;
|
|
19
|
+
ws.close();
|
|
20
|
+
reject(new Error(`Connection to ${url} timed out after 10s`));
|
|
21
|
+
}
|
|
22
|
+
}, 10_000);
|
|
23
|
+
ws.on('open', () => {
|
|
24
|
+
// Send initial resize so server computes layout
|
|
25
|
+
ws.send(JSON.stringify({ type: 'resize', width: 1024, height: 768 }));
|
|
26
|
+
});
|
|
27
|
+
ws.on('message', (data) => {
|
|
28
|
+
try {
|
|
29
|
+
const msg = JSON.parse(String(data));
|
|
30
|
+
if (msg.type === 'frame') {
|
|
31
|
+
session.layout = msg.layout;
|
|
32
|
+
session.tree = msg.tree;
|
|
33
|
+
if (!resolved) {
|
|
34
|
+
resolved = true;
|
|
35
|
+
clearTimeout(timeout);
|
|
36
|
+
activeSession = session;
|
|
37
|
+
resolve(session);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (msg.type === 'patch' && session.layout) {
|
|
41
|
+
applyPatches(session.layout, msg.patches);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch { /* ignore malformed messages */ }
|
|
45
|
+
});
|
|
46
|
+
ws.on('error', (err) => {
|
|
47
|
+
if (!resolved) {
|
|
48
|
+
resolved = true;
|
|
49
|
+
clearTimeout(timeout);
|
|
50
|
+
reject(new Error(`WebSocket error connecting to ${url}: ${err.message}`));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
ws.on('close', () => {
|
|
54
|
+
if (activeSession === session)
|
|
55
|
+
activeSession = null;
|
|
56
|
+
if (!resolved) {
|
|
57
|
+
resolved = true;
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
reject(new Error(`Connection to ${url} closed before first frame`));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export function getSession() {
|
|
65
|
+
return activeSession;
|
|
66
|
+
}
|
|
67
|
+
export function disconnect() {
|
|
68
|
+
if (activeSession) {
|
|
69
|
+
activeSession.ws.close();
|
|
70
|
+
activeSession = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
75
|
+
*/
|
|
76
|
+
export function sendClick(session, x, y) {
|
|
77
|
+
return sendAndWaitForUpdate(session, {
|
|
78
|
+
type: 'event',
|
|
79
|
+
eventType: 'onClick',
|
|
80
|
+
x,
|
|
81
|
+
y,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Send a sequence of key events to type text into the focused element.
|
|
86
|
+
*/
|
|
87
|
+
export function sendType(session, text) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
90
|
+
reject(new Error('Not connected'));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Send each character as keydown + keyup
|
|
94
|
+
for (const char of text) {
|
|
95
|
+
const keyEvent = {
|
|
96
|
+
type: 'key',
|
|
97
|
+
eventType: 'onKeyDown',
|
|
98
|
+
key: char,
|
|
99
|
+
code: `Key${char.toUpperCase()}`,
|
|
100
|
+
shiftKey: false,
|
|
101
|
+
ctrlKey: false,
|
|
102
|
+
metaKey: false,
|
|
103
|
+
altKey: false,
|
|
104
|
+
};
|
|
105
|
+
session.ws.send(JSON.stringify(keyEvent));
|
|
106
|
+
session.ws.send(JSON.stringify({ ...keyEvent, eventType: 'onKeyUp' }));
|
|
107
|
+
}
|
|
108
|
+
// Wait briefly for server to process and send update
|
|
109
|
+
waitForNextUpdate(session).then(resolve).catch(reject);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Send a special key (Enter, Tab, Escape, etc.)
|
|
114
|
+
*/
|
|
115
|
+
export function sendKey(session, key, modifiers) {
|
|
116
|
+
return sendAndWaitForUpdate(session, {
|
|
117
|
+
type: 'key',
|
|
118
|
+
eventType: 'onKeyDown',
|
|
119
|
+
key,
|
|
120
|
+
code: key,
|
|
121
|
+
shiftKey: modifiers?.shift ?? false,
|
|
122
|
+
ctrlKey: modifiers?.ctrl ?? false,
|
|
123
|
+
metaKey: modifiers?.meta ?? false,
|
|
124
|
+
altKey: modifiers?.alt ?? false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
|
|
129
|
+
* Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
|
|
130
|
+
*/
|
|
131
|
+
export function sendFileUpload(session, paths, opts) {
|
|
132
|
+
const payload = { type: 'file', paths };
|
|
133
|
+
if (opts?.click) {
|
|
134
|
+
payload.x = opts.click.x;
|
|
135
|
+
payload.y = opts.click.y;
|
|
136
|
+
}
|
|
137
|
+
if (opts?.strategy)
|
|
138
|
+
payload.strategy = opts.strategy;
|
|
139
|
+
if (opts?.drop) {
|
|
140
|
+
payload.dropX = opts.drop.x;
|
|
141
|
+
payload.dropY = opts.drop.y;
|
|
142
|
+
}
|
|
143
|
+
return sendAndWaitForUpdate(session, payload);
|
|
144
|
+
}
|
|
145
|
+
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
146
|
+
export function sendListboxPick(session, label, opts) {
|
|
147
|
+
const payload = { type: 'listboxPick', label };
|
|
148
|
+
if (opts?.exact !== undefined)
|
|
149
|
+
payload.exact = opts.exact;
|
|
150
|
+
if (opts?.open) {
|
|
151
|
+
payload.openX = opts.open.x;
|
|
152
|
+
payload.openY = opts.open.y;
|
|
153
|
+
}
|
|
154
|
+
return sendAndWaitForUpdate(session, payload);
|
|
155
|
+
}
|
|
156
|
+
/** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
|
|
157
|
+
export function sendSelectOption(session, x, y, option) {
|
|
158
|
+
return sendAndWaitForUpdate(session, {
|
|
159
|
+
type: 'selectOption',
|
|
160
|
+
x,
|
|
161
|
+
y,
|
|
162
|
+
...option,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
|
|
166
|
+
export function sendWheel(session, deltaY, opts) {
|
|
167
|
+
return sendAndWaitForUpdate(session, {
|
|
168
|
+
type: 'wheel',
|
|
169
|
+
deltaY,
|
|
170
|
+
deltaX: opts?.deltaX ?? 0,
|
|
171
|
+
...(opts?.x !== undefined ? { x: opts.x } : {}),
|
|
172
|
+
...(opts?.y !== undefined ? { y: opts.y } : {}),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Build a flat accessibility tree from the raw UI tree + layout.
|
|
177
|
+
* This is a standalone reimplementation that works with raw JSON —
|
|
178
|
+
* no dependency on @geometra/core.
|
|
179
|
+
*/
|
|
180
|
+
export function buildA11yTree(tree, layout) {
|
|
181
|
+
return walkNode(tree, layout, []);
|
|
182
|
+
}
|
|
183
|
+
/** Roles that usually matter for interaction or landmarks (non-wrapper noise). */
|
|
184
|
+
const COMPACT_INDEX_ROLES = new Set([
|
|
185
|
+
'link',
|
|
186
|
+
'button',
|
|
187
|
+
'textbox',
|
|
188
|
+
'checkbox',
|
|
189
|
+
'radio',
|
|
190
|
+
'combobox',
|
|
191
|
+
'heading',
|
|
192
|
+
'img',
|
|
193
|
+
'navigation',
|
|
194
|
+
'main',
|
|
195
|
+
'form',
|
|
196
|
+
'article',
|
|
197
|
+
'listitem',
|
|
198
|
+
]);
|
|
199
|
+
const LANDMARK_ROLES = new Set([
|
|
200
|
+
'banner',
|
|
201
|
+
'navigation',
|
|
202
|
+
'main',
|
|
203
|
+
'search',
|
|
204
|
+
'form',
|
|
205
|
+
'article',
|
|
206
|
+
'region',
|
|
207
|
+
'contentinfo',
|
|
208
|
+
]);
|
|
209
|
+
const FORM_FIELD_ROLES = new Set([
|
|
210
|
+
'textbox',
|
|
211
|
+
'combobox',
|
|
212
|
+
'checkbox',
|
|
213
|
+
'radio',
|
|
214
|
+
]);
|
|
215
|
+
const ACTION_ROLES = new Set([
|
|
216
|
+
'button',
|
|
217
|
+
'link',
|
|
218
|
+
]);
|
|
219
|
+
const DIALOG_ROLES = new Set([
|
|
220
|
+
'dialog',
|
|
221
|
+
'alertdialog',
|
|
222
|
+
]);
|
|
223
|
+
function intersectsViewport(b, vw, vh) {
|
|
224
|
+
return (b.width > 0 &&
|
|
225
|
+
b.height > 0 &&
|
|
226
|
+
b.x + b.width > 0 &&
|
|
227
|
+
b.y + b.height > 0 &&
|
|
228
|
+
b.x < vw &&
|
|
229
|
+
b.y < vh);
|
|
230
|
+
}
|
|
231
|
+
function includeInCompactIndex(n) {
|
|
232
|
+
if (n.focusable)
|
|
233
|
+
return true;
|
|
234
|
+
if (COMPACT_INDEX_ROLES.has(n.role))
|
|
235
|
+
return true;
|
|
236
|
+
if (n.role === 'text' && n.name && n.name.trim().length > 0)
|
|
237
|
+
return true;
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Flat list of actionable / semantic nodes in the viewport, sorted with focusable first
|
|
242
|
+
* then top-to-bottom reading order. Intended to minimize LLM tokens vs a full nested tree.
|
|
243
|
+
*/
|
|
244
|
+
export function buildCompactUiIndex(root, options) {
|
|
245
|
+
const vw = options?.viewportWidth ?? root.bounds.width;
|
|
246
|
+
const vh = options?.viewportHeight ?? root.bounds.height;
|
|
247
|
+
const maxNodes = options?.maxNodes ?? 400;
|
|
248
|
+
const acc = [];
|
|
249
|
+
function walk(n) {
|
|
250
|
+
if (includeInCompactIndex(n) && intersectsViewport(n.bounds, vw, vh)) {
|
|
251
|
+
const name = n.name && n.name.length > 240 ? `${n.name.slice(0, 239)}\u2026` : n.name;
|
|
252
|
+
acc.push({
|
|
253
|
+
role: n.role,
|
|
254
|
+
...(name ? { name } : {}),
|
|
255
|
+
...(n.state && Object.keys(n.state).length > 0 ? { state: n.state } : {}),
|
|
256
|
+
bounds: { ...n.bounds },
|
|
257
|
+
path: n.path,
|
|
258
|
+
focusable: n.focusable,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
for (const c of n.children)
|
|
262
|
+
walk(c);
|
|
263
|
+
}
|
|
264
|
+
walk(root);
|
|
265
|
+
acc.sort((a, b) => {
|
|
266
|
+
if (a.focusable !== b.focusable)
|
|
267
|
+
return a.focusable ? -1 : 1;
|
|
268
|
+
if (a.bounds.y !== b.bounds.y)
|
|
269
|
+
return a.bounds.y - b.bounds.y;
|
|
270
|
+
return a.bounds.x - b.bounds.x;
|
|
271
|
+
});
|
|
272
|
+
if (acc.length > maxNodes)
|
|
273
|
+
return { nodes: acc.slice(0, maxNodes), truncated: true };
|
|
274
|
+
return { nodes: acc, truncated: false };
|
|
275
|
+
}
|
|
276
|
+
export function summarizeCompactIndex(nodes, maxLines = 80) {
|
|
277
|
+
const lines = [];
|
|
278
|
+
const slice = nodes.slice(0, maxLines);
|
|
279
|
+
for (const n of slice) {
|
|
280
|
+
const nm = n.name ? ` "${truncateUiText(n.name, 48)}"` : '';
|
|
281
|
+
const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
|
|
282
|
+
const foc = n.focusable ? ' *' : '';
|
|
283
|
+
const b = n.bounds;
|
|
284
|
+
lines.push(`${n.role}${nm} (${b.x},${b.y} ${b.width}x${b.height}) path=${JSON.stringify(n.path)}${st}${foc}`);
|
|
285
|
+
}
|
|
286
|
+
if (nodes.length > maxLines) {
|
|
287
|
+
lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
|
|
288
|
+
}
|
|
289
|
+
return lines.join('\n');
|
|
290
|
+
}
|
|
291
|
+
function cloneBounds(bounds) {
|
|
292
|
+
return { ...bounds };
|
|
293
|
+
}
|
|
294
|
+
function cloneState(state) {
|
|
295
|
+
if (!state)
|
|
296
|
+
return undefined;
|
|
297
|
+
const next = {};
|
|
298
|
+
if (state.disabled)
|
|
299
|
+
next.disabled = true;
|
|
300
|
+
if (state.expanded !== undefined)
|
|
301
|
+
next.expanded = state.expanded;
|
|
302
|
+
if (state.selected !== undefined)
|
|
303
|
+
next.selected = state.selected;
|
|
304
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
305
|
+
}
|
|
306
|
+
function clonePath(path) {
|
|
307
|
+
return [...path];
|
|
308
|
+
}
|
|
309
|
+
function sortByBounds(items) {
|
|
310
|
+
return items.sort((a, b) => {
|
|
311
|
+
if (a.bounds.y !== b.bounds.y)
|
|
312
|
+
return a.bounds.y - b.bounds.y;
|
|
313
|
+
return a.bounds.x - b.bounds.x;
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function collectDescendants(node, predicate) {
|
|
317
|
+
const out = [];
|
|
318
|
+
function walk(current) {
|
|
319
|
+
for (const child of current.children) {
|
|
320
|
+
if (predicate(child))
|
|
321
|
+
out.push(child);
|
|
322
|
+
walk(child);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
walk(node);
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
function firstNamedDescendant(node, allowedRoles) {
|
|
329
|
+
const queue = [...node.children];
|
|
330
|
+
while (queue.length > 0) {
|
|
331
|
+
const current = queue.shift();
|
|
332
|
+
if ((!allowedRoles || allowedRoles.has(current.role)) && current.name && current.name.trim().length > 0) {
|
|
333
|
+
return current.name;
|
|
334
|
+
}
|
|
335
|
+
queue.push(...current.children);
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
function containerName(node) {
|
|
340
|
+
return node.name ?? firstNamedDescendant(node, new Set(['heading', 'text']));
|
|
341
|
+
}
|
|
342
|
+
function listItemName(node) {
|
|
343
|
+
return node.name ?? firstNamedDescendant(node, new Set(['heading', 'text', 'link', 'button']));
|
|
344
|
+
}
|
|
345
|
+
function truncateForModel(value, max = 120) {
|
|
346
|
+
if (!value)
|
|
347
|
+
return undefined;
|
|
348
|
+
return value.length > max ? `${value.slice(0, max - 1)}\u2026` : value;
|
|
349
|
+
}
|
|
350
|
+
function toFieldModel(node) {
|
|
351
|
+
return {
|
|
352
|
+
role: node.role,
|
|
353
|
+
...(truncateForModel(node.name, 160) ? { name: truncateForModel(node.name, 160) } : {}),
|
|
354
|
+
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
355
|
+
bounds: cloneBounds(node.bounds),
|
|
356
|
+
path: clonePath(node.path),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function toActionModel(node) {
|
|
360
|
+
return {
|
|
361
|
+
role: node.role,
|
|
362
|
+
...(truncateForModel(node.name, 160) ? { name: truncateForModel(node.name, 160) } : {}),
|
|
363
|
+
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
364
|
+
bounds: cloneBounds(node.bounds),
|
|
365
|
+
path: clonePath(node.path),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function toLandmarkModel(node) {
|
|
369
|
+
return {
|
|
370
|
+
role: node.role,
|
|
371
|
+
...(truncateForModel(containerName(node), 120) ? { name: truncateForModel(containerName(node), 120) } : {}),
|
|
372
|
+
bounds: cloneBounds(node.bounds),
|
|
373
|
+
path: clonePath(node.path),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Build a compact, webpage-shaped model from the accessibility tree:
|
|
378
|
+
* landmarks, dialogs, forms, and lists with short previews.
|
|
379
|
+
*/
|
|
380
|
+
export function buildPageModel(root, options) {
|
|
381
|
+
const maxFieldsPerForm = options?.maxFieldsPerForm ?? 12;
|
|
382
|
+
const maxActionsPerContainer = options?.maxActionsPerContainer ?? 8;
|
|
383
|
+
const maxItemsPerList = options?.maxItemsPerList ?? 5;
|
|
384
|
+
const landmarks = [];
|
|
385
|
+
const forms = [];
|
|
386
|
+
const dialogs = [];
|
|
387
|
+
const lists = [];
|
|
388
|
+
function walk(node) {
|
|
389
|
+
if (LANDMARK_ROLES.has(node.role)) {
|
|
390
|
+
landmarks.push(toLandmarkModel(node));
|
|
391
|
+
}
|
|
392
|
+
if (node.role === 'form') {
|
|
393
|
+
const fields = sortByBounds(collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role)));
|
|
394
|
+
const actions = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
|
|
395
|
+
const name = truncateForModel(containerName(node), 120);
|
|
396
|
+
forms.push({
|
|
397
|
+
...(name ? { name } : {}),
|
|
398
|
+
bounds: cloneBounds(node.bounds),
|
|
399
|
+
path: clonePath(node.path),
|
|
400
|
+
fieldCount: fields.length,
|
|
401
|
+
actionCount: actions.length,
|
|
402
|
+
fields: fields.slice(0, maxFieldsPerForm).map(toFieldModel),
|
|
403
|
+
actions: actions.slice(0, maxActionsPerContainer).map(toActionModel),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (DIALOG_ROLES.has(node.role)) {
|
|
407
|
+
const actions = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
|
|
408
|
+
const name = truncateForModel(containerName(node), 120);
|
|
409
|
+
dialogs.push({
|
|
410
|
+
...(name ? { name } : {}),
|
|
411
|
+
bounds: cloneBounds(node.bounds),
|
|
412
|
+
path: clonePath(node.path),
|
|
413
|
+
actionCount: actions.length,
|
|
414
|
+
actions: actions.slice(0, maxActionsPerContainer).map(toActionModel),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
if (node.role === 'list') {
|
|
418
|
+
const items = sortByBounds(collectDescendants(node, candidate => candidate.role === 'listitem'));
|
|
419
|
+
const preview = items
|
|
420
|
+
.map(item => truncateForModel(listItemName(item), 80))
|
|
421
|
+
.filter((value) => !!value)
|
|
422
|
+
.slice(0, maxItemsPerList);
|
|
423
|
+
const name = truncateForModel(containerName(node), 120);
|
|
424
|
+
lists.push({
|
|
425
|
+
...(name ? { name } : {}),
|
|
426
|
+
bounds: cloneBounds(node.bounds),
|
|
427
|
+
path: clonePath(node.path),
|
|
428
|
+
itemCount: items.length,
|
|
429
|
+
itemsPreview: preview,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
for (const child of node.children)
|
|
433
|
+
walk(child);
|
|
434
|
+
}
|
|
435
|
+
walk(root);
|
|
436
|
+
return {
|
|
437
|
+
viewport: {
|
|
438
|
+
width: root.bounds.width,
|
|
439
|
+
height: root.bounds.height,
|
|
440
|
+
},
|
|
441
|
+
landmarks: sortByBounds(landmarks),
|
|
442
|
+
forms: sortByBounds(forms),
|
|
443
|
+
dialogs: sortByBounds(dialogs),
|
|
444
|
+
lists: sortByBounds(lists),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
export function summarizePageModel(model, maxLines = 10) {
|
|
448
|
+
const lines = [];
|
|
449
|
+
if (model.landmarks.length > 0) {
|
|
450
|
+
const landmarks = model.landmarks
|
|
451
|
+
.slice(0, 5)
|
|
452
|
+
.map(landmark => landmark.name ? `${landmark.role} "${truncateUiText(landmark.name, 36)}"` : landmark.role)
|
|
453
|
+
.join(', ');
|
|
454
|
+
lines.push(`landmarks: ${landmarks}`);
|
|
455
|
+
}
|
|
456
|
+
for (const form of model.forms.slice(0, 3)) {
|
|
457
|
+
const name = form.name ? ` "${truncateUiText(form.name, 40)}"` : '';
|
|
458
|
+
lines.push(`form${name}: ${form.fieldCount} fields, ${form.actionCount} actions`);
|
|
459
|
+
}
|
|
460
|
+
for (const dialog of model.dialogs.slice(0, 2)) {
|
|
461
|
+
const name = dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : '';
|
|
462
|
+
lines.push(`dialog${name}: ${dialog.actionCount} actions`);
|
|
463
|
+
}
|
|
464
|
+
for (const list of model.lists.slice(0, 3)) {
|
|
465
|
+
const name = list.name ? ` "${truncateUiText(list.name, 40)}"` : '';
|
|
466
|
+
const preview = list.itemsPreview.length > 0
|
|
467
|
+
? ` [${list.itemsPreview.map(item => `"${truncateUiText(item, 24)}"`).join(', ')}]`
|
|
468
|
+
: '';
|
|
469
|
+
lines.push(`list${name}: ${list.itemCount} items${preview}`);
|
|
470
|
+
}
|
|
471
|
+
if (lines.length === 0) {
|
|
472
|
+
return `viewport ${model.viewport.width}x${model.viewport.height}; no common page structures detected`;
|
|
473
|
+
}
|
|
474
|
+
return lines.slice(0, maxLines).join('\n');
|
|
475
|
+
}
|
|
476
|
+
function pathKey(path) {
|
|
477
|
+
return path.join('.');
|
|
478
|
+
}
|
|
479
|
+
function compactNodeLabel(node) {
|
|
480
|
+
if (node.name)
|
|
481
|
+
return `${node.role} "${truncateUiText(node.name, 40)}"`;
|
|
482
|
+
return `${node.role} @ ${JSON.stringify(node.path)}`;
|
|
483
|
+
}
|
|
484
|
+
function formatStateValue(value) {
|
|
485
|
+
return value === undefined ? 'unset' : String(value);
|
|
486
|
+
}
|
|
487
|
+
function diffCompactNodes(before, after) {
|
|
488
|
+
const changes = [];
|
|
489
|
+
if (before.role !== after.role)
|
|
490
|
+
changes.push(`role ${before.role} -> ${after.role}`);
|
|
491
|
+
if ((before.name ?? '') !== (after.name ?? '')) {
|
|
492
|
+
changes.push(`name ${JSON.stringify(truncateUiText(before.name ?? 'unset', 32))} -> ${JSON.stringify(truncateUiText(after.name ?? 'unset', 32))}`);
|
|
493
|
+
}
|
|
494
|
+
const beforeState = before.state ?? {};
|
|
495
|
+
const afterState = after.state ?? {};
|
|
496
|
+
for (const key of ['disabled', 'expanded', 'selected']) {
|
|
497
|
+
if (beforeState[key] !== afterState[key]) {
|
|
498
|
+
changes.push(`${key} ${formatStateValue(beforeState[key])} -> ${formatStateValue(afterState[key])}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const moved = Math.abs(before.bounds.x - after.bounds.x) + Math.abs(before.bounds.y - after.bounds.y);
|
|
502
|
+
const resized = Math.abs(before.bounds.width - after.bounds.width) + Math.abs(before.bounds.height - after.bounds.height);
|
|
503
|
+
if (moved >= 8 || resized >= 8) {
|
|
504
|
+
changes.push(`bounds (${before.bounds.x},${before.bounds.y} ${before.bounds.width}x${before.bounds.height}) -> (${after.bounds.x},${after.bounds.y} ${after.bounds.width}x${after.bounds.height})`);
|
|
505
|
+
}
|
|
506
|
+
return changes;
|
|
507
|
+
}
|
|
508
|
+
function pageContainerKey(value) {
|
|
509
|
+
return `${pathKey(value.path)}|${value.name ?? ''}`;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Compare two accessibility trees at the compact viewport layer plus a few
|
|
513
|
+
* higher-level structures (dialogs, forms, lists).
|
|
514
|
+
*/
|
|
515
|
+
export function buildUiDelta(before, after, options) {
|
|
516
|
+
const maxNodes = options?.maxNodes ?? 250;
|
|
517
|
+
const beforeCompact = buildCompactUiIndex(before, { maxNodes }).nodes;
|
|
518
|
+
const afterCompact = buildCompactUiIndex(after, { maxNodes }).nodes;
|
|
519
|
+
const beforeMap = new Map(beforeCompact.map(node => [pathKey(node.path), node]));
|
|
520
|
+
const afterMap = new Map(afterCompact.map(node => [pathKey(node.path), node]));
|
|
521
|
+
const added = [];
|
|
522
|
+
const removed = [];
|
|
523
|
+
const updated = [];
|
|
524
|
+
for (const [key, afterNode] of afterMap) {
|
|
525
|
+
const beforeNode = beforeMap.get(key);
|
|
526
|
+
if (!beforeNode) {
|
|
527
|
+
added.push(afterNode);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const changes = diffCompactNodes(beforeNode, afterNode);
|
|
531
|
+
if (changes.length > 0)
|
|
532
|
+
updated.push({ before: beforeNode, after: afterNode, changes });
|
|
533
|
+
}
|
|
534
|
+
for (const [key, beforeNode] of beforeMap) {
|
|
535
|
+
if (!afterMap.has(key))
|
|
536
|
+
removed.push(beforeNode);
|
|
537
|
+
}
|
|
538
|
+
const beforePage = buildPageModel(before);
|
|
539
|
+
const afterPage = buildPageModel(after);
|
|
540
|
+
const beforeDialogs = new Map(beforePage.dialogs.map(dialog => [pageContainerKey(dialog), dialog]));
|
|
541
|
+
const afterDialogs = new Map(afterPage.dialogs.map(dialog => [pageContainerKey(dialog), dialog]));
|
|
542
|
+
const dialogsOpened = [...afterDialogs.entries()]
|
|
543
|
+
.filter(([key]) => !beforeDialogs.has(key))
|
|
544
|
+
.map(([, value]) => value);
|
|
545
|
+
const dialogsClosed = [...beforeDialogs.entries()]
|
|
546
|
+
.filter(([key]) => !afterDialogs.has(key))
|
|
547
|
+
.map(([, value]) => value);
|
|
548
|
+
const beforeForms = new Map(beforePage.forms.map(form => [pageContainerKey(form), form]));
|
|
549
|
+
const afterForms = new Map(afterPage.forms.map(form => [pageContainerKey(form), form]));
|
|
550
|
+
const formsAppeared = [...afterForms.entries()]
|
|
551
|
+
.filter(([key]) => !beforeForms.has(key))
|
|
552
|
+
.map(([, value]) => value);
|
|
553
|
+
const formsRemoved = [...beforeForms.entries()]
|
|
554
|
+
.filter(([key]) => !afterForms.has(key))
|
|
555
|
+
.map(([, value]) => value);
|
|
556
|
+
const beforeLists = new Map(beforePage.lists.map(list => [pathKey(list.path), list]));
|
|
557
|
+
const afterLists = new Map(afterPage.lists.map(list => [pathKey(list.path), list]));
|
|
558
|
+
const listCountsChanged = [];
|
|
559
|
+
for (const [key, afterList] of afterLists) {
|
|
560
|
+
const beforeList = beforeLists.get(key);
|
|
561
|
+
if (beforeList && beforeList.itemCount !== afterList.itemCount) {
|
|
562
|
+
listCountsChanged.push({
|
|
563
|
+
...(afterList.name ? { name: afterList.name } : {}),
|
|
564
|
+
path: clonePath(afterList.path),
|
|
565
|
+
beforeCount: beforeList.itemCount,
|
|
566
|
+
afterCount: afterList.itemCount,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
added,
|
|
572
|
+
removed,
|
|
573
|
+
updated,
|
|
574
|
+
dialogsOpened,
|
|
575
|
+
dialogsClosed,
|
|
576
|
+
formsAppeared,
|
|
577
|
+
formsRemoved,
|
|
578
|
+
listCountsChanged,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
export function hasUiDelta(delta) {
|
|
582
|
+
return (delta.added.length > 0 ||
|
|
583
|
+
delta.removed.length > 0 ||
|
|
584
|
+
delta.updated.length > 0 ||
|
|
585
|
+
delta.dialogsOpened.length > 0 ||
|
|
586
|
+
delta.dialogsClosed.length > 0 ||
|
|
587
|
+
delta.formsAppeared.length > 0 ||
|
|
588
|
+
delta.formsRemoved.length > 0 ||
|
|
589
|
+
delta.listCountsChanged.length > 0);
|
|
590
|
+
}
|
|
591
|
+
export function summarizeUiDelta(delta, maxLines = 14) {
|
|
592
|
+
const lines = [];
|
|
593
|
+
for (const dialog of delta.dialogsOpened.slice(0, 2)) {
|
|
594
|
+
lines.push(`+ dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} opened`);
|
|
595
|
+
}
|
|
596
|
+
for (const dialog of delta.dialogsClosed.slice(0, 2)) {
|
|
597
|
+
lines.push(`- dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} closed`);
|
|
598
|
+
}
|
|
599
|
+
for (const form of delta.formsAppeared.slice(0, 2)) {
|
|
600
|
+
lines.push(`+ form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} appeared (${form.fieldCount} fields)`);
|
|
601
|
+
}
|
|
602
|
+
for (const form of delta.formsRemoved.slice(0, 2)) {
|
|
603
|
+
lines.push(`- form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} removed`);
|
|
604
|
+
}
|
|
605
|
+
for (const list of delta.listCountsChanged.slice(0, 3)) {
|
|
606
|
+
lines.push(`~ list${list.name ? ` "${truncateUiText(list.name, 40)}"` : ''} items ${list.beforeCount} -> ${list.afterCount}`);
|
|
607
|
+
}
|
|
608
|
+
for (const update of delta.updated.slice(0, 5)) {
|
|
609
|
+
lines.push(`~ ${compactNodeLabel(update.after)}: ${update.changes.join('; ')}`);
|
|
610
|
+
}
|
|
611
|
+
for (const node of delta.added.slice(0, 4)) {
|
|
612
|
+
lines.push(`+ ${compactNodeLabel(node)}`);
|
|
613
|
+
}
|
|
614
|
+
for (const node of delta.removed.slice(0, 4)) {
|
|
615
|
+
lines.push(`- ${compactNodeLabel(node)}`);
|
|
616
|
+
}
|
|
617
|
+
if (lines.length === 0) {
|
|
618
|
+
return 'No semantic changes detected in the compact viewport model.';
|
|
619
|
+
}
|
|
620
|
+
if (lines.length > maxLines) {
|
|
621
|
+
const hidden = lines.length - maxLines;
|
|
622
|
+
return `${lines.slice(0, maxLines).join('\n')}\n… and ${hidden} more changes`;
|
|
623
|
+
}
|
|
624
|
+
return lines.join('\n');
|
|
625
|
+
}
|
|
626
|
+
function truncateUiText(s, max) {
|
|
627
|
+
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
|
|
628
|
+
}
|
|
629
|
+
function walkNode(element, layout, path) {
|
|
630
|
+
const kind = element.kind;
|
|
631
|
+
const semantic = element.semantic;
|
|
632
|
+
const props = element.props;
|
|
633
|
+
const handlers = element.handlers;
|
|
634
|
+
const role = inferRole(kind, semantic, handlers);
|
|
635
|
+
const name = inferName(kind, semantic, props);
|
|
636
|
+
const focusable = !!(handlers?.onClick || handlers?.onKeyDown || handlers?.onKeyUp ||
|
|
637
|
+
handlers?.onCompositionStart || handlers?.onCompositionUpdate || handlers?.onCompositionEnd);
|
|
638
|
+
const bounds = {
|
|
639
|
+
x: layout.x ?? 0,
|
|
640
|
+
y: layout.y ?? 0,
|
|
641
|
+
width: layout.width ?? 0,
|
|
642
|
+
height: layout.height ?? 0,
|
|
643
|
+
};
|
|
644
|
+
const state = {};
|
|
645
|
+
if (semantic?.ariaDisabled)
|
|
646
|
+
state.disabled = true;
|
|
647
|
+
if (semantic?.ariaExpanded !== undefined)
|
|
648
|
+
state.expanded = !!semantic.ariaExpanded;
|
|
649
|
+
if (semantic?.ariaSelected !== undefined)
|
|
650
|
+
state.selected = !!semantic.ariaSelected;
|
|
651
|
+
const children = [];
|
|
652
|
+
const elementChildren = element.children;
|
|
653
|
+
const layoutChildren = layout.children;
|
|
654
|
+
if (elementChildren && layoutChildren) {
|
|
655
|
+
for (let i = 0; i < elementChildren.length; i++) {
|
|
656
|
+
if (elementChildren[i] && layoutChildren[i]) {
|
|
657
|
+
children.push(walkNode(elementChildren[i], layoutChildren[i], [...path, i]));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
role,
|
|
663
|
+
...(name ? { name } : {}),
|
|
664
|
+
...(Object.keys(state).length > 0 ? { state } : {}),
|
|
665
|
+
bounds,
|
|
666
|
+
path,
|
|
667
|
+
children,
|
|
668
|
+
focusable,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function inferRole(kind, semantic, handlers) {
|
|
672
|
+
if (semantic?.role)
|
|
673
|
+
return semantic.role;
|
|
674
|
+
const tag = semantic?.tag;
|
|
675
|
+
if (kind === 'text') {
|
|
676
|
+
if (tag && /^h[1-6]$/.test(tag))
|
|
677
|
+
return 'heading';
|
|
678
|
+
return 'text';
|
|
679
|
+
}
|
|
680
|
+
if (kind === 'image')
|
|
681
|
+
return 'img';
|
|
682
|
+
if (kind === 'scene3d')
|
|
683
|
+
return 'img';
|
|
684
|
+
// box
|
|
685
|
+
if (tag === 'nav')
|
|
686
|
+
return 'navigation';
|
|
687
|
+
if (tag === 'main')
|
|
688
|
+
return 'main';
|
|
689
|
+
if (tag === 'article')
|
|
690
|
+
return 'article';
|
|
691
|
+
if (tag === 'section')
|
|
692
|
+
return 'region';
|
|
693
|
+
if (tag === 'ul' || tag === 'ol')
|
|
694
|
+
return 'list';
|
|
695
|
+
if (tag === 'li')
|
|
696
|
+
return 'listitem';
|
|
697
|
+
if (tag === 'form')
|
|
698
|
+
return 'form';
|
|
699
|
+
if (tag === 'button')
|
|
700
|
+
return 'button';
|
|
701
|
+
if (tag === 'input')
|
|
702
|
+
return 'textbox';
|
|
703
|
+
if (handlers?.onClick)
|
|
704
|
+
return 'button';
|
|
705
|
+
return 'group';
|
|
706
|
+
}
|
|
707
|
+
function inferName(kind, semantic, props) {
|
|
708
|
+
if (semantic?.ariaLabel)
|
|
709
|
+
return semantic.ariaLabel;
|
|
710
|
+
if (kind === 'text' && props?.text)
|
|
711
|
+
return props.text;
|
|
712
|
+
if (kind === 'image')
|
|
713
|
+
return (semantic?.alt ?? props?.alt);
|
|
714
|
+
return semantic?.alt;
|
|
715
|
+
}
|
|
716
|
+
function applyPatches(layout, patches) {
|
|
717
|
+
for (const patch of patches) {
|
|
718
|
+
let node = layout;
|
|
719
|
+
for (const idx of patch.path) {
|
|
720
|
+
const children = node.children;
|
|
721
|
+
if (!children?.[idx])
|
|
722
|
+
break;
|
|
723
|
+
node = children[idx];
|
|
724
|
+
}
|
|
725
|
+
if (patch.x !== undefined)
|
|
726
|
+
node.x = patch.x;
|
|
727
|
+
if (patch.y !== undefined)
|
|
728
|
+
node.y = patch.y;
|
|
729
|
+
if (patch.width !== undefined)
|
|
730
|
+
node.width = patch.width;
|
|
731
|
+
if (patch.height !== undefined)
|
|
732
|
+
node.height = patch.height;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function sendAndWaitForUpdate(session, message) {
|
|
736
|
+
return new Promise((resolve, reject) => {
|
|
737
|
+
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
738
|
+
reject(new Error('Not connected'));
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
session.ws.send(JSON.stringify(message));
|
|
742
|
+
waitForNextUpdate(session).then(resolve).catch(reject);
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
function waitForNextUpdate(session) {
|
|
746
|
+
return new Promise((resolve, reject) => {
|
|
747
|
+
const onMessage = (data) => {
|
|
748
|
+
try {
|
|
749
|
+
const msg = JSON.parse(String(data));
|
|
750
|
+
if (msg.type === 'error') {
|
|
751
|
+
cleanup();
|
|
752
|
+
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (msg.type === 'frame') {
|
|
756
|
+
session.layout = msg.layout;
|
|
757
|
+
session.tree = msg.tree;
|
|
758
|
+
cleanup();
|
|
759
|
+
resolve();
|
|
760
|
+
}
|
|
761
|
+
else if (msg.type === 'patch' && session.layout) {
|
|
762
|
+
applyPatches(session.layout, msg.patches);
|
|
763
|
+
cleanup();
|
|
764
|
+
resolve();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
catch { /* ignore */ }
|
|
768
|
+
};
|
|
769
|
+
// Resolve after timeout even if no update comes (action may not change layout)
|
|
770
|
+
const timeout = setTimeout(() => { cleanup(); resolve(); }, 2000);
|
|
771
|
+
function cleanup() {
|
|
772
|
+
clearTimeout(timeout);
|
|
773
|
+
session.ws.off('message', onMessage);
|
|
774
|
+
}
|
|
775
|
+
session.ws.on('message', onMessage);
|
|
776
|
+
});
|
|
777
|
+
}
|