@geometra/mcp 1.18.1 → 1.19.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 +22 -11
- package/dist/__tests__/connect-utils.test.d.ts +1 -0
- package/dist/__tests__/connect-utils.test.js +78 -0
- package/dist/__tests__/session-model.test.js +73 -10
- package/dist/connect-utils.d.ts +18 -0
- package/dist/connect-utils.js +94 -0
- package/dist/index.js +27 -0
- package/dist/proxy-spawn.d.ts +20 -0
- package/dist/proxy-spawn.js +131 -0
- package/dist/server.js +131 -52
- package/dist/session.d.ts +121 -45
- package/dist/session.js +434 -89
- package/package.json +4 -2
package/dist/session.js
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
|
+
import { spawnGeometraProxy } from './proxy-spawn.js';
|
|
2
3
|
let activeSession = null;
|
|
4
|
+
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
5
|
+
function shutdownPreviousSession() {
|
|
6
|
+
const prev = activeSession;
|
|
7
|
+
if (!prev)
|
|
8
|
+
return;
|
|
9
|
+
activeSession = null;
|
|
10
|
+
try {
|
|
11
|
+
prev.ws.close();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
/* ignore */
|
|
15
|
+
}
|
|
16
|
+
if (prev.proxyChild) {
|
|
17
|
+
try {
|
|
18
|
+
prev.proxyChild.kill('SIGTERM');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* ignore */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
3
25
|
/**
|
|
4
26
|
* Connect to a running Geometra server. Waits for the first frame so that
|
|
5
27
|
* layout/tree state is available immediately after connection.
|
|
6
28
|
*/
|
|
7
29
|
export function connect(url) {
|
|
8
30
|
return new Promise((resolve, reject) => {
|
|
9
|
-
|
|
10
|
-
activeSession.ws.close();
|
|
11
|
-
activeSession = null;
|
|
12
|
-
}
|
|
31
|
+
shutdownPreviousSession();
|
|
13
32
|
const ws = new WebSocket(url);
|
|
14
33
|
const session = { ws, layout: null, tree: null, url };
|
|
15
34
|
let resolved = false;
|
|
@@ -51,8 +70,17 @@ export function connect(url) {
|
|
|
51
70
|
}
|
|
52
71
|
});
|
|
53
72
|
ws.on('close', () => {
|
|
54
|
-
if (activeSession === session)
|
|
73
|
+
if (activeSession === session) {
|
|
55
74
|
activeSession = null;
|
|
75
|
+
if (session.proxyChild) {
|
|
76
|
+
try {
|
|
77
|
+
session.proxyChild.kill('SIGTERM');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
56
84
|
if (!resolved) {
|
|
57
85
|
resolved = true;
|
|
58
86
|
clearTimeout(timeout);
|
|
@@ -61,14 +89,39 @@ export function connect(url) {
|
|
|
61
89
|
});
|
|
62
90
|
});
|
|
63
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Start geometra-proxy for `pageUrl`, connect to its WebSocket, and attach the child
|
|
94
|
+
* process to the session so disconnect / reconnect can clean it up.
|
|
95
|
+
*/
|
|
96
|
+
export async function connectThroughProxy(options) {
|
|
97
|
+
const { child, wsUrl } = await spawnGeometraProxy({
|
|
98
|
+
pageUrl: options.pageUrl,
|
|
99
|
+
port: options.port ?? 0,
|
|
100
|
+
headless: options.headless,
|
|
101
|
+
width: options.width,
|
|
102
|
+
height: options.height,
|
|
103
|
+
slowMo: options.slowMo,
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
const session = await connect(wsUrl);
|
|
107
|
+
session.proxyChild = child;
|
|
108
|
+
return session;
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
try {
|
|
112
|
+
child.kill('SIGTERM');
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* ignore */
|
|
116
|
+
}
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
64
120
|
export function getSession() {
|
|
65
121
|
return activeSession;
|
|
66
122
|
}
|
|
67
123
|
export function disconnect() {
|
|
68
|
-
|
|
69
|
-
activeSession.ws.close();
|
|
70
|
-
activeSession = null;
|
|
71
|
-
}
|
|
124
|
+
shutdownPreviousSession();
|
|
72
125
|
}
|
|
73
126
|
/**
|
|
74
127
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
@@ -220,6 +273,101 @@ const DIALOG_ROLES = new Set([
|
|
|
220
273
|
'dialog',
|
|
221
274
|
'alertdialog',
|
|
222
275
|
]);
|
|
276
|
+
const FIELD_LABEL_ROLES = new Set(['textbox', 'combobox', 'checkbox', 'radio']);
|
|
277
|
+
const CONTENT_NAME_ROLES = new Set(['heading', 'text']);
|
|
278
|
+
function encodePath(path) {
|
|
279
|
+
return path.length === 0 ? 'root' : path.map(part => part.toString(36)).join('.');
|
|
280
|
+
}
|
|
281
|
+
function decodePath(encoded) {
|
|
282
|
+
if (encoded === 'root')
|
|
283
|
+
return [];
|
|
284
|
+
const parts = encoded.split('.');
|
|
285
|
+
const out = [];
|
|
286
|
+
for (const part of parts) {
|
|
287
|
+
const value = Number.parseInt(part, 36);
|
|
288
|
+
if (!Number.isFinite(value) || value < 0)
|
|
289
|
+
return null;
|
|
290
|
+
out.push(value);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
export function nodeIdForPath(path) {
|
|
295
|
+
return `n:${encodePath(path)}`;
|
|
296
|
+
}
|
|
297
|
+
function sectionPrefix(kind) {
|
|
298
|
+
if (kind === 'landmark')
|
|
299
|
+
return 'lm';
|
|
300
|
+
if (kind === 'form')
|
|
301
|
+
return 'fm';
|
|
302
|
+
if (kind === 'dialog')
|
|
303
|
+
return 'dg';
|
|
304
|
+
return 'ls';
|
|
305
|
+
}
|
|
306
|
+
function sectionIdForPath(kind, path) {
|
|
307
|
+
return `${sectionPrefix(kind)}:${encodePath(path)}`;
|
|
308
|
+
}
|
|
309
|
+
function parseSectionId(id) {
|
|
310
|
+
const [prefix, encoded] = id.split(':', 2);
|
|
311
|
+
if (!prefix || !encoded)
|
|
312
|
+
return null;
|
|
313
|
+
const path = decodePath(encoded);
|
|
314
|
+
if (!path)
|
|
315
|
+
return null;
|
|
316
|
+
if (prefix === 'lm')
|
|
317
|
+
return { kind: 'landmark', path };
|
|
318
|
+
if (prefix === 'fm')
|
|
319
|
+
return { kind: 'form', path };
|
|
320
|
+
if (prefix === 'dg')
|
|
321
|
+
return { kind: 'dialog', path };
|
|
322
|
+
if (prefix === 'ls')
|
|
323
|
+
return { kind: 'list', path };
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
function normalizeUiText(value) {
|
|
327
|
+
return value.replace(/\s+/g, ' ').replace(/\s*\u00a0\s*/g, ' ').trim();
|
|
328
|
+
}
|
|
329
|
+
function trimPunctuation(value) {
|
|
330
|
+
return value.replace(/[:*]+$/g, '').trim();
|
|
331
|
+
}
|
|
332
|
+
function sanitizeInlineName(value, max = 120) {
|
|
333
|
+
if (!value)
|
|
334
|
+
return undefined;
|
|
335
|
+
const normalized = normalizeUiText(value);
|
|
336
|
+
if (!normalized)
|
|
337
|
+
return undefined;
|
|
338
|
+
return normalized.length > max ? `${normalized.slice(0, max - 1)}\u2026` : normalized;
|
|
339
|
+
}
|
|
340
|
+
function sanitizeFieldName(value, max = 80) {
|
|
341
|
+
const normalized = sanitizeInlineName(value, max + 8);
|
|
342
|
+
if (!normalized)
|
|
343
|
+
return undefined;
|
|
344
|
+
const trimmed = trimPunctuation(normalized);
|
|
345
|
+
if (!trimmed)
|
|
346
|
+
return undefined;
|
|
347
|
+
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}\u2026` : trimmed;
|
|
348
|
+
}
|
|
349
|
+
function looksNoisyContainerName(value) {
|
|
350
|
+
const starCount = (value.match(/\*/g) ?? []).length;
|
|
351
|
+
const labelMatches = value.match(/\b(first name|last name|email|phone|country|location|resume|linkedin|portfolio|website|city)\b/gi);
|
|
352
|
+
const tokenCount = value.split(/\s+/).filter(Boolean).length;
|
|
353
|
+
if (value.length > 90)
|
|
354
|
+
return true;
|
|
355
|
+
if (starCount >= 2)
|
|
356
|
+
return true;
|
|
357
|
+
if ((labelMatches?.length ?? 0) >= 3)
|
|
358
|
+
return true;
|
|
359
|
+
if (tokenCount >= 12)
|
|
360
|
+
return true;
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
function sanitizeContainerName(value, max = 80) {
|
|
364
|
+
const normalized = sanitizeInlineName(value, max + 24);
|
|
365
|
+
if (!normalized)
|
|
366
|
+
return undefined;
|
|
367
|
+
if (looksNoisyContainerName(normalized))
|
|
368
|
+
return undefined;
|
|
369
|
+
return normalized.length > max ? `${normalized.slice(0, max - 1)}\u2026` : normalized;
|
|
370
|
+
}
|
|
223
371
|
function intersectsViewport(b, vw, vh) {
|
|
224
372
|
return (b.width > 0 &&
|
|
225
373
|
b.height > 0 &&
|
|
@@ -248,8 +396,9 @@ export function buildCompactUiIndex(root, options) {
|
|
|
248
396
|
const acc = [];
|
|
249
397
|
function walk(n) {
|
|
250
398
|
if (includeInCompactIndex(n) && intersectsViewport(n.bounds, vw, vh)) {
|
|
251
|
-
const name = n.name
|
|
399
|
+
const name = sanitizeInlineName(n.name, 240);
|
|
252
400
|
acc.push({
|
|
401
|
+
id: nodeIdForPath(n.path),
|
|
253
402
|
role: n.role,
|
|
254
403
|
...(name ? { name } : {}),
|
|
255
404
|
...(n.state && Object.keys(n.state).length > 0 ? { state: n.state } : {}),
|
|
@@ -281,7 +430,7 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
|
|
|
281
430
|
const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
|
|
282
431
|
const foc = n.focusable ? ' *' : '';
|
|
283
432
|
const b = n.bounds;
|
|
284
|
-
lines.push(`${n.role}${nm} (${b.x},${b.y} ${b.width}x${b.height})
|
|
433
|
+
lines.push(`${n.id} ${n.role}${nm} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
|
|
285
434
|
}
|
|
286
435
|
if (nodes.length > maxLines) {
|
|
287
436
|
lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
|
|
@@ -336,51 +485,138 @@ function firstNamedDescendant(node, allowedRoles) {
|
|
|
336
485
|
}
|
|
337
486
|
return undefined;
|
|
338
487
|
}
|
|
339
|
-
function
|
|
340
|
-
|
|
488
|
+
function findNodeByPath(root, path) {
|
|
489
|
+
let current = root;
|
|
490
|
+
for (const index of path) {
|
|
491
|
+
if (!current.children[index])
|
|
492
|
+
return null;
|
|
493
|
+
current = current.children[index];
|
|
494
|
+
}
|
|
495
|
+
return current;
|
|
496
|
+
}
|
|
497
|
+
function countFocusableNodes(root) {
|
|
498
|
+
let count = 0;
|
|
499
|
+
function walk(node) {
|
|
500
|
+
if (node.focusable)
|
|
501
|
+
count++;
|
|
502
|
+
for (const child of node.children)
|
|
503
|
+
walk(child);
|
|
504
|
+
}
|
|
505
|
+
walk(root);
|
|
506
|
+
return count;
|
|
507
|
+
}
|
|
508
|
+
function dedupeStrings(values, max) {
|
|
509
|
+
const seen = new Set();
|
|
510
|
+
const out = [];
|
|
511
|
+
for (const value of values) {
|
|
512
|
+
if (!value || seen.has(value))
|
|
513
|
+
continue;
|
|
514
|
+
seen.add(value);
|
|
515
|
+
out.push(value);
|
|
516
|
+
if (out.length >= max)
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
function fieldLabel(node) {
|
|
522
|
+
return sanitizeFieldName(node.name, 80);
|
|
523
|
+
}
|
|
524
|
+
function contentPreviewName(node) {
|
|
525
|
+
if (node.role === 'heading')
|
|
526
|
+
return sanitizeInlineName(node.name, 80);
|
|
527
|
+
if (node.role === 'text')
|
|
528
|
+
return sanitizeInlineName(node.name, 80);
|
|
529
|
+
if (node.role === 'link' || node.role === 'button')
|
|
530
|
+
return sanitizeInlineName(node.name, 80);
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
function sectionDisplayName(node, kind) {
|
|
534
|
+
const headingName = sanitizeInlineName(firstNamedDescendant(node, new Set(['heading'])), 80);
|
|
535
|
+
if (headingName)
|
|
536
|
+
return headingName;
|
|
537
|
+
if (kind === 'list') {
|
|
538
|
+
return sanitizeContainerName(node.name, 80)
|
|
539
|
+
?? sanitizeInlineName(firstNamedDescendant(node, new Set(['text', 'link', 'button'])), 80);
|
|
540
|
+
}
|
|
541
|
+
if (kind === 'landmark') {
|
|
542
|
+
return sanitizeContainerName(node.name, 80)
|
|
543
|
+
?? sanitizeInlineName(firstNamedDescendant(node, CONTENT_NAME_ROLES), 80);
|
|
544
|
+
}
|
|
545
|
+
return sanitizeContainerName(node.name, 80);
|
|
341
546
|
}
|
|
342
547
|
function listItemName(node) {
|
|
343
|
-
return node.name ?? firstNamedDescendant(node, new Set(['heading', 'text', 'link', 'button']));
|
|
548
|
+
return sanitizeInlineName(node.name ?? firstNamedDescendant(node, new Set(['heading', 'text', 'link', 'button'])), 80);
|
|
344
549
|
}
|
|
345
|
-
function
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return
|
|
550
|
+
function textPreview(node, maxItems) {
|
|
551
|
+
const texts = collectDescendants(node, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
|
|
552
|
+
!!sanitizeInlineName(candidate.name, 90));
|
|
553
|
+
return dedupeStrings(texts.map(candidate => contentPreviewName(candidate)), maxItems);
|
|
349
554
|
}
|
|
350
|
-
function
|
|
555
|
+
function primaryAction(node) {
|
|
351
556
|
return {
|
|
557
|
+
id: nodeIdForPath(node.path),
|
|
352
558
|
role: node.role,
|
|
353
|
-
...(
|
|
559
|
+
...(sanitizeInlineName(node.name, 80) ? { name: sanitizeInlineName(node.name, 80) } : {}),
|
|
354
560
|
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
355
561
|
bounds: cloneBounds(node.bounds),
|
|
356
|
-
path: clonePath(node.path),
|
|
357
562
|
};
|
|
358
563
|
}
|
|
359
|
-
function
|
|
564
|
+
function toFieldModel(node, includeBounds = true) {
|
|
360
565
|
return {
|
|
566
|
+
id: nodeIdForPath(node.path),
|
|
361
567
|
role: node.role,
|
|
362
|
-
...(
|
|
568
|
+
...(fieldLabel(node) ? { name: fieldLabel(node) } : {}),
|
|
363
569
|
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
364
|
-
bounds: cloneBounds(node.bounds),
|
|
365
|
-
|
|
570
|
+
...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function toActionModel(node, includeBounds = true) {
|
|
574
|
+
return {
|
|
575
|
+
id: nodeIdForPath(node.path),
|
|
576
|
+
role: node.role,
|
|
577
|
+
...(sanitizeInlineName(node.name, 80) ? { name: sanitizeInlineName(node.name, 80) } : {}),
|
|
578
|
+
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
579
|
+
...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
|
|
366
580
|
};
|
|
367
581
|
}
|
|
368
582
|
function toLandmarkModel(node) {
|
|
583
|
+
const name = sectionDisplayName(node, 'landmark');
|
|
369
584
|
return {
|
|
585
|
+
id: sectionIdForPath('landmark', node.path),
|
|
370
586
|
role: node.role,
|
|
371
|
-
...(
|
|
587
|
+
...(name ? { name } : {}),
|
|
372
588
|
bounds: cloneBounds(node.bounds),
|
|
373
|
-
path: clonePath(node.path),
|
|
374
589
|
};
|
|
375
590
|
}
|
|
591
|
+
function inferPageArchetypes(model) {
|
|
592
|
+
const out = new Set();
|
|
593
|
+
const landmarkRoles = new Set(model.landmarks.map(landmark => landmark.role));
|
|
594
|
+
if (landmarkRoles.has('navigation') && landmarkRoles.has('main'))
|
|
595
|
+
out.add('shell');
|
|
596
|
+
if (model.summary.formCount > 0)
|
|
597
|
+
out.add('form');
|
|
598
|
+
if (model.summary.dialogCount > 0)
|
|
599
|
+
out.add('dialog');
|
|
600
|
+
if (model.summary.listCount > 0)
|
|
601
|
+
out.add('results');
|
|
602
|
+
if (model.summary.focusableCount >= 14 && model.summary.listCount >= 2 && model.summary.formCount === 0) {
|
|
603
|
+
out.add('dashboard');
|
|
604
|
+
}
|
|
605
|
+
if (model.summary.formCount === 0 &&
|
|
606
|
+
model.summary.dialogCount === 0 &&
|
|
607
|
+
model.summary.listCount <= 1 &&
|
|
608
|
+
model.summary.focusableCount <= 8) {
|
|
609
|
+
out.add('content');
|
|
610
|
+
}
|
|
611
|
+
return [...out];
|
|
612
|
+
}
|
|
376
613
|
/**
|
|
377
|
-
* Build a
|
|
378
|
-
*
|
|
614
|
+
* Build a summary-first, stable-ID webpage model from the accessibility tree.
|
|
615
|
+
* Use {@link expandPageSection} to fetch details for a specific section on demand.
|
|
379
616
|
*/
|
|
380
617
|
export function buildPageModel(root, options) {
|
|
381
|
-
const
|
|
382
|
-
const
|
|
383
|
-
const maxItemsPerList = options?.maxItemsPerList ?? 5;
|
|
618
|
+
const maxPrimaryActions = options?.maxPrimaryActions ?? 6;
|
|
619
|
+
const maxSectionsPerKind = options?.maxSectionsPerKind ?? 8;
|
|
384
620
|
const landmarks = [];
|
|
385
621
|
const forms = [];
|
|
386
622
|
const dialogs = [];
|
|
@@ -390,86 +626,192 @@ export function buildPageModel(root, options) {
|
|
|
390
626
|
landmarks.push(toLandmarkModel(node));
|
|
391
627
|
}
|
|
392
628
|
if (node.role === 'form') {
|
|
393
|
-
const fields =
|
|
394
|
-
const actions =
|
|
395
|
-
const name =
|
|
629
|
+
const fields = collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role));
|
|
630
|
+
const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
|
|
631
|
+
const name = sectionDisplayName(node, 'form');
|
|
396
632
|
forms.push({
|
|
633
|
+
id: sectionIdForPath('form', node.path),
|
|
634
|
+
role: node.role,
|
|
397
635
|
...(name ? { name } : {}),
|
|
398
636
|
bounds: cloneBounds(node.bounds),
|
|
399
|
-
path: clonePath(node.path),
|
|
400
637
|
fieldCount: fields.length,
|
|
401
638
|
actionCount: actions.length,
|
|
402
|
-
fields: fields.slice(0, maxFieldsPerForm).map(toFieldModel),
|
|
403
|
-
actions: actions.slice(0, maxActionsPerContainer).map(toActionModel),
|
|
404
639
|
});
|
|
405
640
|
}
|
|
406
641
|
if (DIALOG_ROLES.has(node.role)) {
|
|
407
|
-
const
|
|
408
|
-
const
|
|
642
|
+
const fields = collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role));
|
|
643
|
+
const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
|
|
644
|
+
const name = sectionDisplayName(node, 'dialog');
|
|
409
645
|
dialogs.push({
|
|
646
|
+
id: sectionIdForPath('dialog', node.path),
|
|
647
|
+
role: node.role,
|
|
410
648
|
...(name ? { name } : {}),
|
|
411
649
|
bounds: cloneBounds(node.bounds),
|
|
412
|
-
|
|
650
|
+
fieldCount: fields.length,
|
|
413
651
|
actionCount: actions.length,
|
|
414
|
-
actions: actions.slice(0, maxActionsPerContainer).map(toActionModel),
|
|
415
652
|
});
|
|
416
653
|
}
|
|
417
654
|
if (node.role === 'list') {
|
|
418
|
-
const items =
|
|
419
|
-
const
|
|
420
|
-
.map(item => truncateForModel(listItemName(item), 80))
|
|
421
|
-
.filter((value) => !!value)
|
|
422
|
-
.slice(0, maxItemsPerList);
|
|
423
|
-
const name = truncateForModel(containerName(node), 120);
|
|
655
|
+
const items = collectDescendants(node, candidate => candidate.role === 'listitem');
|
|
656
|
+
const name = sectionDisplayName(node, 'list');
|
|
424
657
|
lists.push({
|
|
658
|
+
id: sectionIdForPath('list', node.path),
|
|
659
|
+
role: node.role,
|
|
425
660
|
...(name ? { name } : {}),
|
|
426
661
|
bounds: cloneBounds(node.bounds),
|
|
427
|
-
path: clonePath(node.path),
|
|
428
662
|
itemCount: items.length,
|
|
429
|
-
itemsPreview: preview,
|
|
430
663
|
});
|
|
431
664
|
}
|
|
432
665
|
for (const child of node.children)
|
|
433
666
|
walk(child);
|
|
434
667
|
}
|
|
435
668
|
walk(root);
|
|
436
|
-
|
|
669
|
+
const compact = buildCompactUiIndex(root, { maxNodes: 200 });
|
|
670
|
+
const primaryActions = compact.nodes
|
|
671
|
+
.filter(node => node.focusable && ACTION_ROLES.has(node.role))
|
|
672
|
+
.slice(0, maxPrimaryActions)
|
|
673
|
+
.map(node => primaryAction({
|
|
674
|
+
role: node.role,
|
|
675
|
+
name: node.name,
|
|
676
|
+
state: node.state,
|
|
677
|
+
bounds: node.bounds,
|
|
678
|
+
path: node.path,
|
|
679
|
+
children: [],
|
|
680
|
+
focusable: node.focusable,
|
|
681
|
+
}));
|
|
682
|
+
const baseModel = {
|
|
437
683
|
viewport: {
|
|
438
684
|
width: root.bounds.width,
|
|
439
685
|
height: root.bounds.height,
|
|
440
686
|
},
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
687
|
+
summary: {
|
|
688
|
+
landmarkCount: landmarks.length,
|
|
689
|
+
formCount: forms.length,
|
|
690
|
+
dialogCount: dialogs.length,
|
|
691
|
+
listCount: lists.length,
|
|
692
|
+
focusableCount: countFocusableNodes(root),
|
|
693
|
+
},
|
|
694
|
+
primaryActions,
|
|
695
|
+
landmarks: sortByBounds(landmarks).slice(0, maxSectionsPerKind),
|
|
696
|
+
forms: sortByBounds(forms).slice(0, maxSectionsPerKind),
|
|
697
|
+
dialogs: sortByBounds(dialogs).slice(0, maxSectionsPerKind),
|
|
698
|
+
lists: sortByBounds(lists).slice(0, maxSectionsPerKind),
|
|
699
|
+
};
|
|
700
|
+
return {
|
|
701
|
+
...baseModel,
|
|
702
|
+
archetypes: inferPageArchetypes(baseModel),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function headingModels(node, maxHeadings, includeBounds) {
|
|
706
|
+
const headings = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
|
|
707
|
+
return headings.slice(0, maxHeadings).map(heading => ({
|
|
708
|
+
id: nodeIdForPath(heading.path),
|
|
709
|
+
name: sanitizeInlineName(heading.name, 80),
|
|
710
|
+
...(includeBounds ? { bounds: cloneBounds(heading.bounds) } : {}),
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
function nestedListSummaries(node, maxLists, selfPath) {
|
|
714
|
+
const nestedLists = sortByBounds(collectDescendants(node, candidate => candidate.role === 'list' && pathKey(candidate.path) !== pathKey(selfPath)));
|
|
715
|
+
return nestedLists.slice(0, maxLists).map(list => ({
|
|
716
|
+
id: sectionIdForPath('list', list.path),
|
|
717
|
+
role: list.role,
|
|
718
|
+
...(sectionDisplayName(list, 'list') ? { name: sectionDisplayName(list, 'list') } : {}),
|
|
719
|
+
bounds: cloneBounds(list.bounds),
|
|
720
|
+
itemCount: collectDescendants(list, candidate => candidate.role === 'listitem').length,
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
function sectionKindForNode(node) {
|
|
724
|
+
if (node.role === 'form')
|
|
725
|
+
return 'form';
|
|
726
|
+
if (DIALOG_ROLES.has(node.role))
|
|
727
|
+
return 'dialog';
|
|
728
|
+
if (node.role === 'list')
|
|
729
|
+
return 'list';
|
|
730
|
+
if (LANDMARK_ROLES.has(node.role))
|
|
731
|
+
return 'landmark';
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Expand a page-model section by stable ID into richer, on-demand details.
|
|
736
|
+
*/
|
|
737
|
+
export function expandPageSection(root, id, options) {
|
|
738
|
+
const parsed = parseSectionId(id);
|
|
739
|
+
if (!parsed)
|
|
740
|
+
return null;
|
|
741
|
+
const node = findNodeByPath(root, parsed.path);
|
|
742
|
+
if (!node)
|
|
743
|
+
return null;
|
|
744
|
+
const actualKind = sectionKindForNode(node);
|
|
745
|
+
if (actualKind !== parsed.kind)
|
|
746
|
+
return null;
|
|
747
|
+
const maxHeadings = options?.maxHeadings ?? 6;
|
|
748
|
+
const maxFields = options?.maxFields ?? 18;
|
|
749
|
+
const maxActions = options?.maxActions ?? 12;
|
|
750
|
+
const maxLists = options?.maxLists ?? 8;
|
|
751
|
+
const maxItems = options?.maxItems ?? 20;
|
|
752
|
+
const maxTextPreview = options?.maxTextPreview ?? 6;
|
|
753
|
+
const includeBounds = options?.includeBounds ?? false;
|
|
754
|
+
const headingsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
|
|
755
|
+
const fieldsAll = sortByBounds(collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role)));
|
|
756
|
+
const actionsAll = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
|
|
757
|
+
const nestedListsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'list' && pathKey(candidate.path) !== pathKey(node.path)));
|
|
758
|
+
const itemsAll = actualKind === 'list'
|
|
759
|
+
? sortByBounds(collectDescendants(node, candidate => candidate.role === 'listitem'))
|
|
760
|
+
: [];
|
|
761
|
+
const name = sectionDisplayName(node, actualKind);
|
|
762
|
+
return {
|
|
763
|
+
id: sectionIdForPath(actualKind, node.path),
|
|
764
|
+
kind: actualKind,
|
|
765
|
+
role: node.role,
|
|
766
|
+
...(name ? { name } : {}),
|
|
767
|
+
bounds: cloneBounds(node.bounds),
|
|
768
|
+
summary: {
|
|
769
|
+
headingCount: headingsAll.length,
|
|
770
|
+
fieldCount: fieldsAll.length,
|
|
771
|
+
actionCount: actionsAll.length,
|
|
772
|
+
listCount: nestedListsAll.length,
|
|
773
|
+
itemCount: itemsAll.length,
|
|
774
|
+
},
|
|
775
|
+
headings: headingModels(node, maxHeadings, includeBounds),
|
|
776
|
+
fields: fieldsAll.slice(0, maxFields).map(field => toFieldModel(field, includeBounds)),
|
|
777
|
+
actions: actionsAll.slice(0, maxActions).map(action => toActionModel(action, includeBounds)),
|
|
778
|
+
lists: nestedListSummaries(node, maxLists, node.path),
|
|
779
|
+
items: itemsAll.slice(0, maxItems).map(item => ({
|
|
780
|
+
id: nodeIdForPath(item.path),
|
|
781
|
+
...(listItemName(item) ? { name: listItemName(item) } : {}),
|
|
782
|
+
...(includeBounds ? { bounds: cloneBounds(item.bounds) } : {}),
|
|
783
|
+
})),
|
|
784
|
+
textPreview: actualKind === 'form' ? [] : textPreview(node, maxTextPreview),
|
|
445
785
|
};
|
|
446
786
|
}
|
|
447
787
|
export function summarizePageModel(model, maxLines = 10) {
|
|
448
788
|
const lines = [];
|
|
449
|
-
if (model.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
789
|
+
if (model.archetypes.length > 0) {
|
|
790
|
+
lines.push(`archetypes: ${model.archetypes.join(', ')}`);
|
|
791
|
+
}
|
|
792
|
+
lines.push(`summary: ${model.summary.landmarkCount} landmarks, ${model.summary.formCount} forms, ${model.summary.dialogCount} dialogs, ${model.summary.listCount} lists, ${model.summary.focusableCount} focusable`);
|
|
793
|
+
for (const landmark of model.landmarks.slice(0, 3)) {
|
|
794
|
+
const name = landmark.name ? ` "${truncateUiText(landmark.name, 32)}"` : '';
|
|
795
|
+
lines.push(`${landmark.id} ${landmark.role}${name}`);
|
|
455
796
|
}
|
|
456
797
|
for (const form of model.forms.slice(0, 3)) {
|
|
457
798
|
const name = form.name ? ` "${truncateUiText(form.name, 40)}"` : '';
|
|
458
|
-
lines.push(
|
|
799
|
+
lines.push(`${form.id} form${name}: ${form.fieldCount} fields, ${form.actionCount} actions`);
|
|
459
800
|
}
|
|
460
801
|
for (const dialog of model.dialogs.slice(0, 2)) {
|
|
461
802
|
const name = dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : '';
|
|
462
|
-
lines.push(
|
|
803
|
+
lines.push(`${dialog.id} dialog${name}: ${dialog.fieldCount} fields, ${dialog.actionCount} actions`);
|
|
463
804
|
}
|
|
464
805
|
for (const list of model.lists.slice(0, 3)) {
|
|
465
806
|
const name = list.name ? ` "${truncateUiText(list.name, 40)}"` : '';
|
|
466
|
-
|
|
467
|
-
? ` [${list.itemsPreview.map(item => `"${truncateUiText(item, 24)}"`).join(', ')}]`
|
|
468
|
-
: '';
|
|
469
|
-
lines.push(`list${name}: ${list.itemCount} items${preview}`);
|
|
807
|
+
lines.push(`${list.id} list${name}: ${list.itemCount} items`);
|
|
470
808
|
}
|
|
471
|
-
if (
|
|
472
|
-
|
|
809
|
+
if (model.primaryActions.length > 0) {
|
|
810
|
+
const actions = model.primaryActions
|
|
811
|
+
.slice(0, 4)
|
|
812
|
+
.map(action => action.name ? `${action.id} "${truncateUiText(action.name, 24)}"` : action.id)
|
|
813
|
+
.join(', ');
|
|
814
|
+
lines.push(`primary actions: ${actions}`);
|
|
473
815
|
}
|
|
474
816
|
return lines.slice(0, maxLines).join('\n');
|
|
475
817
|
}
|
|
@@ -478,8 +820,8 @@ function pathKey(path) {
|
|
|
478
820
|
}
|
|
479
821
|
function compactNodeLabel(node) {
|
|
480
822
|
if (node.name)
|
|
481
|
-
return `${node.role} "${truncateUiText(node.name, 40)}"`;
|
|
482
|
-
return `${node.
|
|
823
|
+
return `${node.id} ${node.role} "${truncateUiText(node.name, 40)}"`;
|
|
824
|
+
return `${node.id} ${node.role}`;
|
|
483
825
|
}
|
|
484
826
|
function formatStateValue(value) {
|
|
485
827
|
return value === undefined ? 'unset' : String(value);
|
|
@@ -516,8 +858,8 @@ export function buildUiDelta(before, after, options) {
|
|
|
516
858
|
const maxNodes = options?.maxNodes ?? 250;
|
|
517
859
|
const beforeCompact = buildCompactUiIndex(before, { maxNodes }).nodes;
|
|
518
860
|
const afterCompact = buildCompactUiIndex(after, { maxNodes }).nodes;
|
|
519
|
-
const beforeMap = new Map(beforeCompact.map(node => [
|
|
520
|
-
const afterMap = new Map(afterCompact.map(node => [
|
|
861
|
+
const beforeMap = new Map(beforeCompact.map(node => [node.id, node]));
|
|
862
|
+
const afterMap = new Map(afterCompact.map(node => [node.id, node]));
|
|
521
863
|
const added = [];
|
|
522
864
|
const removed = [];
|
|
523
865
|
const updated = [];
|
|
@@ -537,31 +879,31 @@ export function buildUiDelta(before, after, options) {
|
|
|
537
879
|
}
|
|
538
880
|
const beforePage = buildPageModel(before);
|
|
539
881
|
const afterPage = buildPageModel(after);
|
|
540
|
-
const beforeDialogs = new Map(beforePage.dialogs.map(dialog => [
|
|
541
|
-
const afterDialogs = new Map(afterPage.dialogs.map(dialog => [
|
|
882
|
+
const beforeDialogs = new Map(beforePage.dialogs.map(dialog => [dialog.id, dialog]));
|
|
883
|
+
const afterDialogs = new Map(afterPage.dialogs.map(dialog => [dialog.id, dialog]));
|
|
542
884
|
const dialogsOpened = [...afterDialogs.entries()]
|
|
543
885
|
.filter(([key]) => !beforeDialogs.has(key))
|
|
544
886
|
.map(([, value]) => value);
|
|
545
887
|
const dialogsClosed = [...beforeDialogs.entries()]
|
|
546
888
|
.filter(([key]) => !afterDialogs.has(key))
|
|
547
889
|
.map(([, value]) => value);
|
|
548
|
-
const beforeForms = new Map(beforePage.forms.map(form => [
|
|
549
|
-
const afterForms = new Map(afterPage.forms.map(form => [
|
|
890
|
+
const beforeForms = new Map(beforePage.forms.map(form => [form.id, form]));
|
|
891
|
+
const afterForms = new Map(afterPage.forms.map(form => [form.id, form]));
|
|
550
892
|
const formsAppeared = [...afterForms.entries()]
|
|
551
893
|
.filter(([key]) => !beforeForms.has(key))
|
|
552
894
|
.map(([, value]) => value);
|
|
553
895
|
const formsRemoved = [...beforeForms.entries()]
|
|
554
896
|
.filter(([key]) => !afterForms.has(key))
|
|
555
897
|
.map(([, value]) => value);
|
|
556
|
-
const beforeLists = new Map(beforePage.lists.map(list => [
|
|
557
|
-
const afterLists = new Map(afterPage.lists.map(list => [
|
|
898
|
+
const beforeLists = new Map(beforePage.lists.map(list => [list.id, list]));
|
|
899
|
+
const afterLists = new Map(afterPage.lists.map(list => [list.id, list]));
|
|
558
900
|
const listCountsChanged = [];
|
|
559
901
|
for (const [key, afterList] of afterLists) {
|
|
560
902
|
const beforeList = beforeLists.get(key);
|
|
561
903
|
if (beforeList && beforeList.itemCount !== afterList.itemCount) {
|
|
562
904
|
listCountsChanged.push({
|
|
905
|
+
id: afterList.id,
|
|
563
906
|
...(afterList.name ? { name: afterList.name } : {}),
|
|
564
|
-
path: clonePath(afterList.path),
|
|
565
907
|
beforeCount: beforeList.itemCount,
|
|
566
908
|
afterCount: afterList.itemCount,
|
|
567
909
|
});
|
|
@@ -591,19 +933,19 @@ export function hasUiDelta(delta) {
|
|
|
591
933
|
export function summarizeUiDelta(delta, maxLines = 14) {
|
|
592
934
|
const lines = [];
|
|
593
935
|
for (const dialog of delta.dialogsOpened.slice(0, 2)) {
|
|
594
|
-
lines.push(`+ dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} opened`);
|
|
936
|
+
lines.push(`+ ${dialog.id} dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} opened`);
|
|
595
937
|
}
|
|
596
938
|
for (const dialog of delta.dialogsClosed.slice(0, 2)) {
|
|
597
|
-
lines.push(`- dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} closed`);
|
|
939
|
+
lines.push(`- ${dialog.id} dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} closed`);
|
|
598
940
|
}
|
|
599
941
|
for (const form of delta.formsAppeared.slice(0, 2)) {
|
|
600
|
-
lines.push(`+ form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} appeared (${form.fieldCount} fields)`);
|
|
942
|
+
lines.push(`+ ${form.id} form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} appeared (${form.fieldCount} fields)`);
|
|
601
943
|
}
|
|
602
944
|
for (const form of delta.formsRemoved.slice(0, 2)) {
|
|
603
|
-
lines.push(`- form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} removed`);
|
|
945
|
+
lines.push(`- ${form.id} form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} removed`);
|
|
604
946
|
}
|
|
605
947
|
for (const list of delta.listCountsChanged.slice(0, 3)) {
|
|
606
|
-
lines.push(`~ list${list.name ? ` "${truncateUiText(list.name, 40)}"` : ''} items ${list.beforeCount} -> ${list.afterCount}`);
|
|
948
|
+
lines.push(`~ ${list.id} list${list.name ? ` "${truncateUiText(list.name, 40)}"` : ''} items ${list.beforeCount} -> ${list.afterCount}`);
|
|
607
949
|
}
|
|
608
950
|
for (const update of delta.updated.slice(0, 5)) {
|
|
609
951
|
lines.push(`~ ${compactNodeLabel(update.after)}: ${update.changes.join('; ')}`);
|
|
@@ -756,18 +1098,21 @@ function waitForNextUpdate(session) {
|
|
|
756
1098
|
session.layout = msg.layout;
|
|
757
1099
|
session.tree = msg.tree;
|
|
758
1100
|
cleanup();
|
|
759
|
-
resolve();
|
|
1101
|
+
resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
|
|
760
1102
|
}
|
|
761
1103
|
else if (msg.type === 'patch' && session.layout) {
|
|
762
1104
|
applyPatches(session.layout, msg.patches);
|
|
763
1105
|
cleanup();
|
|
764
|
-
resolve();
|
|
1106
|
+
resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
|
|
765
1107
|
}
|
|
766
1108
|
}
|
|
767
1109
|
catch { /* ignore */ }
|
|
768
1110
|
};
|
|
769
|
-
//
|
|
770
|
-
const timeout = setTimeout(() => {
|
|
1111
|
+
// Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
|
|
1112
|
+
const timeout = setTimeout(() => {
|
|
1113
|
+
cleanup();
|
|
1114
|
+
resolve({ status: 'timed_out', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
|
|
1115
|
+
}, ACTION_UPDATE_TIMEOUT_MS);
|
|
771
1116
|
function cleanup() {
|
|
772
1117
|
clearTimeout(timeout);
|
|
773
1118
|
session.ws.off('message', onMessage);
|