@geometra/mcp 1.59.1 → 1.61.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.
@@ -1,815 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildFormSchemas, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
3
- function node(role, name, bounds, options) {
4
- return {
5
- role,
6
- ...(name ? { name } : {}),
7
- ...(options?.value ? { value: options.value } : {}),
8
- ...(options?.state ? { state: options.state } : {}),
9
- ...(options?.validation ? { validation: options.validation } : {}),
10
- ...(options?.meta ? { meta: options.meta } : {}),
11
- bounds,
12
- path: options?.path ?? [],
13
- children: options?.children ?? [],
14
- focusable: options?.focusable ?? false,
15
- };
16
- }
17
- describe('buildPageModel', () => {
18
- it('builds a summary-first page model with stable section ids', () => {
19
- const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
20
- children: [
21
- node('navigation', 'Primary nav', { x: 0, y: 0, width: 220, height: 80 }, { path: [0] }),
22
- node('main', undefined, { x: 0, y: 80, width: 1024, height: 688 }, {
23
- path: [1],
24
- children: [
25
- node('form', 'Job application', { x: 40, y: 120, width: 520, height: 280 }, {
26
- path: [1, 0],
27
- children: [
28
- node('textbox', 'Full name', { x: 60, y: 160, width: 300, height: 36 }, { path: [1, 0, 0] }),
29
- node('textbox', 'Email', { x: 60, y: 208, width: 300, height: 36 }, { path: [1, 0, 1] }),
30
- node('button', 'Submit application', { x: 60, y: 264, width: 180, height: 40 }, {
31
- path: [1, 0, 2],
32
- focusable: true,
33
- }),
34
- ],
35
- }),
36
- node('list', 'Open roles', { x: 600, y: 120, width: 360, height: 280 }, {
37
- path: [1, 1],
38
- children: [
39
- node('listitem', 'Designer', { x: 620, y: 148, width: 320, height: 32 }, { path: [1, 1, 0] }),
40
- node('listitem', 'Engineer', { x: 620, y: 188, width: 320, height: 32 }, { path: [1, 1, 1] }),
41
- ],
42
- }),
43
- ],
44
- }),
45
- ],
46
- });
47
- const model = buildPageModel(tree);
48
- expect(model.viewport).toEqual({ width: 1024, height: 768 });
49
- expect(model.archetypes).toEqual(expect.arrayContaining(['shell', 'form', 'results']));
50
- expect(model.summary).toEqual({
51
- landmarkCount: 3,
52
- formCount: 1,
53
- dialogCount: 0,
54
- listCount: 1,
55
- focusableCount: 1,
56
- });
57
- expect(model.landmarks.map(item => item.id)).toEqual(['lm:0', 'lm:1', 'lm:1.0']);
58
- expect(model.forms).toHaveLength(1);
59
- expect(model.forms[0]).toMatchObject({
60
- id: 'fm:1.0',
61
- name: 'Job application',
62
- fieldCount: 2,
63
- actionCount: 1,
64
- });
65
- expect(model.lists[0]).toMatchObject({
66
- id: 'ls:1.1',
67
- name: 'Open roles',
68
- itemCount: 2,
69
- });
70
- expect(model.primaryActions).toEqual([
71
- expect.objectContaining({
72
- id: 'n:1.0.2',
73
- role: 'button',
74
- name: 'Submit application',
75
- }),
76
- ]);
77
- });
78
- it('adds nearby item context to repeated primary actions', () => {
79
- const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
80
- children: [
81
- node('main', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
82
- path: [0],
83
- children: [
84
- node('group', undefined, { x: 40, y: 80, width: 320, height: 140 }, {
85
- path: [0, 0],
86
- children: [
87
- node('link', 'Sauce Labs Backpack', { x: 56, y: 96, width: 180, height: 24 }, {
88
- path: [0, 0, 0],
89
- focusable: true,
90
- }),
91
- node('button', 'Add to cart', { x: 56, y: 156, width: 120, height: 36 }, {
92
- path: [0, 0, 1],
93
- focusable: true,
94
- }),
95
- ],
96
- }),
97
- node('group', undefined, { x: 40, y: 252, width: 320, height: 140 }, {
98
- path: [0, 1],
99
- children: [
100
- node('link', 'Sauce Labs Bike Light', { x: 56, y: 268, width: 180, height: 24 }, {
101
- path: [0, 1, 0],
102
- focusable: true,
103
- }),
104
- node('button', 'Add to cart', { x: 56, y: 328, width: 120, height: 36 }, {
105
- path: [0, 1, 1],
106
- focusable: true,
107
- }),
108
- ],
109
- }),
110
- ],
111
- }),
112
- ],
113
- });
114
- const model = buildPageModel(tree, { maxPrimaryActions: 4 });
115
- const addToCartActions = model.primaryActions.filter(action => action.name === 'Add to cart');
116
- expect(addToCartActions).toHaveLength(2);
117
- expect(addToCartActions[0]).toMatchObject({
118
- id: 'n:0.0.1',
119
- context: { item: 'Sauce Labs Backpack' },
120
- });
121
- expect(addToCartActions[1]).toMatchObject({
122
- id: 'n:0.1.1',
123
- context: { item: 'Sauce Labs Bike Light' },
124
- });
125
- });
126
- it('expands a section by id on demand', () => {
127
- const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
128
- children: [
129
- node('main', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
130
- path: [0],
131
- children: [
132
- node('form', 'Job application', { x: 40, y: 120, width: 520, height: 280 }, {
133
- path: [0, 0],
134
- children: [
135
- node('heading', 'Application', { x: 60, y: 132, width: 200, height: 24 }, { path: [0, 0, 0] }),
136
- node('textbox', 'Full name*', { x: 60, y: 160, width: 300, height: 36 }, {
137
- path: [0, 0, 1],
138
- value: 'Taylor Applicant',
139
- state: { required: true },
140
- }),
141
- node('textbox', 'Email:', { x: 60, y: 208, width: 300, height: 36 }, {
142
- path: [0, 0, 2],
143
- value: 'taylor@example.com',
144
- state: { invalid: true, required: true },
145
- validation: { error: 'Please enter a valid email address.' },
146
- }),
147
- node('button', 'Submit application', { x: 60, y: 264, width: 180, height: 40 }, {
148
- path: [0, 0, 3],
149
- focusable: true,
150
- }),
151
- ],
152
- }),
153
- ],
154
- }),
155
- ],
156
- });
157
- const detail = expandPageSection(tree, 'fm:0.0');
158
- expect(detail).toMatchObject({
159
- id: 'fm:0.0',
160
- kind: 'form',
161
- role: 'form',
162
- name: 'Application',
163
- summary: {
164
- headingCount: 1,
165
- fieldCount: 2,
166
- requiredFieldCount: 2,
167
- invalidFieldCount: 1,
168
- actionCount: 1,
169
- },
170
- page: {
171
- fields: { offset: 0, returned: 2, total: 2, hasMore: false },
172
- actions: { offset: 0, returned: 1, total: 1, hasMore: false },
173
- },
174
- });
175
- expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
176
- expect(detail?.fields.map(field => field.value)).toEqual(['Taylor Applicant', 'taylor@example.com']);
177
- expect(detail?.fields[0]?.state).toEqual({ required: true });
178
- expect(detail?.fields[1]?.state).toEqual({ invalid: true, required: true });
179
- expect(detail?.fields[1]?.validation).toEqual({ error: 'Please enter a valid email address.' });
180
- expect(detail?.fields[1]?.visibility).toMatchObject({ intersectsViewport: true, fullyVisible: true });
181
- expect(detail?.actions[0]?.scrollHint).toMatchObject({ status: 'visible' });
182
- expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
183
- expect(detail?.fields[0]).not.toHaveProperty('bounds');
184
- });
185
- it('paginates long sections and carries context on repeated answers', () => {
186
- const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
187
- children: [
188
- node('form', 'Application', { x: 20, y: -120, width: 760, height: 1800 }, {
189
- path: [0],
190
- children: [
191
- node('heading', 'Application', { x: 40, y: 40, width: 240, height: 28 }, { path: [0, 0] }),
192
- node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
193
- path: [0, 1],
194
- state: { required: true },
195
- }),
196
- node('textbox', 'Email', { x: 48, y: 176, width: 320, height: 36 }, {
197
- path: [0, 2],
198
- state: { required: true, invalid: true },
199
- validation: { error: 'Enter a valid email.' },
200
- }),
201
- node('textbox', 'Phone', { x: 48, y: 232, width: 320, height: 36 }, {
202
- path: [0, 3],
203
- state: { required: true },
204
- }),
205
- node('group', undefined, { x: 40, y: 980, width: 520, height: 96 }, {
206
- path: [0, 4],
207
- children: [
208
- node('text', 'Are you legally authorized to work here?', { x: 48, y: 980, width: 340, height: 24 }, {
209
- path: [0, 4, 0],
210
- }),
211
- node('button', 'Yes', { x: 48, y: 1020, width: 88, height: 40 }, {
212
- path: [0, 4, 1],
213
- focusable: true,
214
- }),
215
- node('button', 'No', { x: 148, y: 1020, width: 88, height: 40 }, {
216
- path: [0, 4, 2],
217
- focusable: true,
218
- }),
219
- ],
220
- }),
221
- node('group', undefined, { x: 40, y: 1120, width: 520, height: 96 }, {
222
- path: [0, 5],
223
- children: [
224
- node('text', 'Will you require sponsorship?', { x: 48, y: 1120, width: 260, height: 24 }, {
225
- path: [0, 5, 0],
226
- }),
227
- node('button', 'Yes', { x: 48, y: 1160, width: 88, height: 40 }, {
228
- path: [0, 5, 1],
229
- focusable: true,
230
- }),
231
- node('button', 'No', { x: 148, y: 1160, width: 88, height: 40 }, {
232
- path: [0, 5, 2],
233
- focusable: true,
234
- }),
235
- ],
236
- }),
237
- node('button', 'Submit application', { x: 48, y: 1540, width: 180, height: 40 }, {
238
- path: [0, 6],
239
- focusable: true,
240
- }),
241
- ],
242
- }),
243
- ],
244
- });
245
- const detail = expandPageSection(tree, 'fm:0', {
246
- maxFields: 2,
247
- fieldOffset: 1,
248
- onlyRequiredFields: true,
249
- });
250
- expect(detail).toMatchObject({
251
- summary: {
252
- fieldCount: 3,
253
- requiredFieldCount: 3,
254
- invalidFieldCount: 1,
255
- actionCount: 5,
256
- },
257
- page: {
258
- fields: { offset: 1, returned: 2, total: 3, hasMore: false },
259
- actions: { offset: 0, returned: 5, total: 5, hasMore: false },
260
- },
261
- });
262
- expect(detail?.fields.map(field => field.name)).toEqual(['Email', 'Phone']);
263
- expect(detail?.fields[0]?.scrollHint).toMatchObject({ status: 'visible' });
264
- const authorizedYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Are you legally authorized to work here?');
265
- const sponsorshipYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Will you require sponsorship?');
266
- expect(authorizedYes).toMatchObject({
267
- name: 'Yes',
268
- context: { prompt: 'Are you legally authorized to work here?', section: 'Application' },
269
- visibility: { fullyVisible: false, offscreenBelow: true },
270
- });
271
- expect(sponsorshipYes).toMatchObject({
272
- name: 'Yes',
273
- context: { prompt: 'Will you require sponsorship?', section: 'Application' },
274
- visibility: { fullyVisible: false, offscreenBelow: true },
275
- });
276
- });
277
- it('drops noisy container names and falls back to unnamed summaries', () => {
278
- const tree = node('group', undefined, { x: 0, y: 0, width: 800, height: 600 }, {
279
- children: [
280
- node('form', 'First Name* Last Name* Email* Phone* Country* Location* Resume* LinkedIn*', { x: 20, y: 20, width: 500, height: 400 }, { path: [0] }),
281
- ],
282
- });
283
- const model = buildPageModel(tree);
284
- expect(model.forms[0]?.id).toBe('fm:0');
285
- expect(model.forms[0]?.name).toBeUndefined();
286
- });
287
- });
288
- describe('buildFormSchemas', () => {
289
- it('builds a compact fill-oriented schema and collapses repeated answer groups', () => {
290
- const longEssay = 'Semantic browser automation should be reliable, compact, and predictable across large forms.';
291
- const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
292
- children: [
293
- node('form', 'Application', { x: 32, y: 32, width: 760, height: 1500 }, {
294
- path: [0],
295
- children: [
296
- node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
297
- path: [0, 0],
298
- state: { required: true },
299
- }),
300
- node('combobox', 'Preferred location', { x: 48, y: 180, width: 320, height: 36 }, {
301
- path: [0, 1],
302
- state: { required: true },
303
- value: 'Berlin, Germany',
304
- meta: { controlTag: 'select' },
305
- }),
306
- node('group', undefined, { x: 40, y: 260, width: 520, height: 96 }, {
307
- path: [0, 2],
308
- children: [
309
- node('text', 'Are you legally authorized to work in Germany?', { x: 48, y: 260, width: 360, height: 24 }, {
310
- path: [0, 2, 0],
311
- }),
312
- node('button', 'Yes', { x: 48, y: 300, width: 88, height: 40 }, {
313
- path: [0, 2, 1],
314
- focusable: true,
315
- state: { required: true },
316
- }),
317
- node('button', 'No', { x: 148, y: 300, width: 88, height: 40 }, {
318
- path: [0, 2, 2],
319
- focusable: true,
320
- }),
321
- ],
322
- }),
323
- node('checkbox', 'Share my profile for future roles', { x: 48, y: 400, width: 24, height: 24 }, {
324
- path: [0, 3],
325
- focusable: true,
326
- state: { checked: true },
327
- }),
328
- node('textbox', 'Why Geometra?', { x: 48, y: 480, width: 520, height: 180 }, {
329
- path: [0, 4],
330
- state: { required: true, invalid: true },
331
- validation: { error: 'Please enter at least 40 characters.' },
332
- value: longEssay,
333
- }),
334
- ],
335
- }),
336
- ],
337
- });
338
- const schemas = buildFormSchemas(tree);
339
- expect(schemas).toHaveLength(1);
340
- expect(schemas[0]).toMatchObject({
341
- formId: 'fm:0',
342
- name: 'Application',
343
- fieldCount: 5,
344
- requiredCount: 4,
345
- invalidCount: 1,
346
- });
347
- expect(schemas[0]?.fields).toEqual([
348
- expect.objectContaining({
349
- kind: 'text',
350
- label: 'Full name',
351
- required: true,
352
- }),
353
- expect.objectContaining({
354
- kind: 'choice',
355
- label: 'Preferred location',
356
- choiceType: 'select',
357
- required: true,
358
- value: 'Berlin, Germany',
359
- }),
360
- expect.objectContaining({
361
- kind: 'choice',
362
- label: 'Are you legally authorized to work in Germany?',
363
- choiceType: 'group',
364
- required: true,
365
- optionCount: 2,
366
- booleanChoice: true,
367
- }),
368
- expect.objectContaining({
369
- kind: 'toggle',
370
- label: 'Share my profile for future roles',
371
- checked: true,
372
- controlType: 'checkbox',
373
- }),
374
- expect.objectContaining({
375
- kind: 'text',
376
- label: 'Why Geometra?',
377
- required: true,
378
- invalid: true,
379
- valueLength: longEssay.length,
380
- }),
381
- ]);
382
- expect(schemas[0]?.fields[2]).not.toHaveProperty('options');
383
- });
384
- it('includes explicit options when requested and prefers question prompts over nearby explanatory copy', () => {
385
- const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
386
- children: [
387
- node('form', 'Application', { x: 20, y: 20, width: 760, height: 480 }, {
388
- path: [0],
389
- children: [
390
- node('group', undefined, { x: 32, y: 80, width: 520, height: 120 }, {
391
- path: [0, 0],
392
- children: [
393
- node('text', 'Will you now or in the future require sponsorship?', { x: 40, y: 80, width: 420, height: 24 }, {
394
- path: [0, 0, 0],
395
- }),
396
- node('text', 'This intentionally repeats Yes / No labels to test contextual disambiguation.', { x: 40, y: 112, width: 520, height: 24 }, {
397
- path: [0, 0, 1],
398
- }),
399
- node('button', 'Yes', { x: 40, y: 152, width: 88, height: 40 }, {
400
- path: [0, 0, 2],
401
- focusable: true,
402
- }),
403
- node('button', 'No', { x: 140, y: 152, width: 88, height: 40 }, {
404
- path: [0, 0, 3],
405
- focusable: true,
406
- }),
407
- ],
408
- }),
409
- ],
410
- }),
411
- ],
412
- });
413
- const schema = buildFormSchemas(tree, { includeOptions: true, includeContext: 'always' })[0];
414
- expect(schema?.fields[0]).toMatchObject({
415
- kind: 'choice',
416
- choiceType: 'group',
417
- label: 'Will you now or in the future require sponsorship?',
418
- options: ['Yes', 'No'],
419
- booleanChoice: true,
420
- context: {
421
- section: 'Application',
422
- },
423
- });
424
- });
425
- // Bug #3 regression (v1.43): a React Select / Headless UI / Radix
426
- // autocomplete combobox renders as a plain <input role="textbox"> in the
427
- // accessibility tree. The extractor tags it with meta.isAutocompleteCombobox
428
- // when its ancestry matches the autocomplete-wrapper fingerprint
429
- // (see isAutocompleteComboboxAncestry in packages/proxy/src/extractor.ts,
430
- // which mirrors isAutocompleteCombobox in packages/proxy/src/dom-actions.ts).
431
- // The form-schema classifier MUST re-tag that field as choice/listbox so
432
- // fill_form routes it through pick_listbox_option — otherwise Greenhouse
433
- // Remix Country pickers (and every other React Select) get fed through
434
- // the plain text-fill path, the controlled form state never commits, and
435
- // Submit fails with "This field is required" while the field looks filled.
436
- it('re-classifies autocomplete-combobox-wrapped textboxes as choice/listbox', () => {
437
- const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
438
- children: [
439
- node('form', 'Address', { x: 20, y: 20, width: 760, height: 480 }, {
440
- path: [0],
441
- children: [
442
- // A plain text field — should stay classified as `text`.
443
- node('textbox', 'Postal code', { x: 40, y: 60, width: 200, height: 36 }, {
444
- path: [0, 0],
445
- state: { required: true },
446
- meta: { controlTag: 'input', inputType: 'text' },
447
- }),
448
- // The React Select Country picker: role=textbox but the
449
- // extractor flagged its ancestry as autocomplete-combobox. The
450
- // classifier must re-tag this as choice/listbox.
451
- node('textbox', 'Country', { x: 40, y: 120, width: 320, height: 36 }, {
452
- path: [0, 1],
453
- state: { required: true, invalid: true },
454
- meta: {
455
- controlTag: 'input',
456
- isAutocompleteCombobox: true,
457
- },
458
- }),
459
- // A real native <combobox> wrapping a <select> — should stay
460
- // classified as choice/select (NOT listbox) because the
461
- // controlTag is 'select' and no autocomplete flag is set.
462
- node('combobox', 'State / Region', { x: 40, y: 180, width: 320, height: 36 }, {
463
- path: [0, 2],
464
- state: { required: true },
465
- value: 'California',
466
- meta: { controlTag: 'select' },
467
- }),
468
- ],
469
- }),
470
- ],
471
- });
472
- const schemas = buildFormSchemas(tree);
473
- expect(schemas).toHaveLength(1);
474
- const fields = schemas[0]?.fields ?? [];
475
- expect(fields).toHaveLength(3);
476
- expect(fields[0]).toMatchObject({
477
- kind: 'text',
478
- label: 'Postal code',
479
- required: true,
480
- });
481
- // Critical assertion: the Country textbox comes out as a listbox
482
- // choice so fill_form will use pick_listbox_option.
483
- expect(fields[1]).toMatchObject({
484
- kind: 'choice',
485
- choiceType: 'listbox',
486
- label: 'Country',
487
- required: true,
488
- invalid: true,
489
- });
490
- // Back-compat: native <select> still classified as choice/select.
491
- expect(fields[2]).toMatchObject({
492
- kind: 'choice',
493
- choiceType: 'select',
494
- label: 'State / Region',
495
- required: true,
496
- value: 'California',
497
- });
498
- });
499
- });
500
- describe('buildFormRequiredSnapshot', () => {
501
- it('keeps offscreen required fields with bounds, visibility, and scroll hints', () => {
502
- const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
503
- children: [
504
- node('form', 'Application', { x: 20, y: -160, width: 760, height: 2200 }, {
505
- path: [0],
506
- children: [
507
- node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
508
- path: [0, 0],
509
- state: { required: true },
510
- }),
511
- node('combobox', 'Preferred location', { x: 48, y: 940, width: 320, height: 36 }, {
512
- path: [0, 1],
513
- state: { required: true },
514
- meta: { controlTag: 'select' },
515
- }),
516
- node('group', undefined, { x: 40, y: 1260, width: 520, height: 96 }, {
517
- path: [0, 2],
518
- children: [
519
- node('text', 'Will you require sponsorship?', { x: 48, y: 1260, width: 320, height: 24 }, {
520
- path: [0, 2, 0],
521
- }),
522
- node('button', 'Yes', { x: 48, y: 1300, width: 88, height: 40 }, {
523
- path: [0, 2, 1],
524
- focusable: true,
525
- state: { required: true },
526
- }),
527
- node('button', 'No', { x: 148, y: 1300, width: 88, height: 40 }, {
528
- path: [0, 2, 2],
529
- focusable: true,
530
- }),
531
- ],
532
- }),
533
- ],
534
- }),
535
- ],
536
- });
537
- const snapshot = buildFormRequiredSnapshot(tree, { includeOptions: true });
538
- expect(snapshot).toHaveLength(1);
539
- expect(snapshot[0]).toMatchObject({
540
- formId: 'fm:0',
541
- name: 'Application',
542
- requiredCount: 3,
543
- fields: [
544
- {
545
- id: 'ff:0.0',
546
- label: 'Full name',
547
- visibility: { intersectsViewport: true, fullyVisible: true },
548
- scrollHint: { status: 'visible' },
549
- bounds: { x: 48, y: 120, width: 320, height: 36 },
550
- },
551
- {
552
- id: 'ff:0.1',
553
- label: 'Preferred location',
554
- choiceType: 'select',
555
- visibility: { intersectsViewport: false, offscreenBelow: true },
556
- scrollHint: { status: 'offscreen' },
557
- bounds: { x: 48, y: 940, width: 320, height: 36 },
558
- },
559
- {
560
- id: 'ff:0.2',
561
- label: 'Will you require sponsorship?',
562
- choiceType: 'group',
563
- visibility: { intersectsViewport: false, offscreenBelow: true },
564
- scrollHint: { status: 'offscreen' },
565
- bounds: { x: 40, y: 1260, width: 520, height: 96 },
566
- options: ['Yes', 'No'],
567
- },
568
- ],
569
- });
570
- });
571
- });
572
- describe('buildUiDelta', () => {
573
- it('captures opened dialogs, state changes, and list count changes', () => {
574
- const before = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
575
- children: [
576
- node('main', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
577
- path: [0],
578
- children: [
579
- node('button', 'Save', { x: 40, y: 40, width: 120, height: 40 }, {
580
- path: [0, 0],
581
- focusable: true,
582
- }),
583
- node('list', 'Results', { x: 40, y: 120, width: 400, height: 240 }, {
584
- path: [0, 1],
585
- children: [
586
- node('listitem', 'Row 1', { x: 60, y: 144, width: 360, height: 32 }, { path: [0, 1, 0] }),
587
- node('listitem', 'Row 2', { x: 60, y: 184, width: 360, height: 32 }, { path: [0, 1, 1] }),
588
- ],
589
- }),
590
- ],
591
- }),
592
- ],
593
- });
594
- const after = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
595
- children: [
596
- node('main', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
597
- path: [0],
598
- children: [
599
- node('button', 'Save', { x: 40, y: 40, width: 120, height: 40 }, {
600
- path: [0, 0],
601
- focusable: true,
602
- state: { disabled: true },
603
- }),
604
- node('list', 'Results', { x: 40, y: 120, width: 400, height: 280 }, {
605
- path: [0, 1],
606
- children: [
607
- node('listitem', 'Row 1', { x: 60, y: 144, width: 360, height: 32 }, { path: [0, 1, 0] }),
608
- node('listitem', 'Row 2', { x: 60, y: 184, width: 360, height: 32 }, { path: [0, 1, 1] }),
609
- node('listitem', 'Row 3', { x: 60, y: 224, width: 360, height: 32 }, { path: [0, 1, 2] }),
610
- ],
611
- }),
612
- node('dialog', 'Save complete', { x: 520, y: 80, width: 280, height: 180 }, {
613
- path: [0, 2],
614
- children: [
615
- node('button', 'Close', { x: 620, y: 200, width: 100, height: 36 }, {
616
- path: [0, 2, 0],
617
- focusable: true,
618
- }),
619
- ],
620
- }),
621
- ],
622
- }),
623
- ],
624
- });
625
- const delta = buildUiDelta(before, after);
626
- expect(hasUiDelta(delta)).toBe(true);
627
- expect(delta.dialogsOpened).toHaveLength(1);
628
- expect(delta.dialogsOpened[0]?.id).toBe('dg:0.2');
629
- expect(delta.dialogsOpened[0]?.name).toBe('Save complete');
630
- expect(delta.listCountsChanged).toEqual([
631
- { id: 'ls:0.1', name: 'Results', beforeCount: 2, afterCount: 3 },
632
- ]);
633
- expect(delta.updated.some(update => update.after.name === 'Save' && update.changes.some(change => change.includes('disabled')))).toBe(true);
634
- const summary = summarizeUiDelta(delta);
635
- expect(summary).toContain('+ dg:0.2 dialog "Save complete" opened');
636
- expect(summary).toContain('~ ls:0.1 list "Results" items 2 -> 3');
637
- expect(summary).toContain('~ n:0.0 button "Save": disabled unset -> true');
638
- });
639
- it('surfaces checkbox checked-state changes in semantic deltas', () => {
640
- const before = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
641
- children: [
642
- node('form', 'Application', { x: 20, y: 20, width: 600, height: 220 }, {
643
- path: [0],
644
- children: [
645
- node('checkbox', 'New York, NY', { x: 40, y: 80, width: 24, height: 24 }, {
646
- path: [0, 0],
647
- focusable: true,
648
- state: { checked: false },
649
- }),
650
- ],
651
- }),
652
- ],
653
- });
654
- const after = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
655
- children: [
656
- node('form', 'Application', { x: 20, y: 20, width: 600, height: 220 }, {
657
- path: [0],
658
- children: [
659
- node('checkbox', 'New York, NY', { x: 40, y: 80, width: 24, height: 24 }, {
660
- path: [0, 0],
661
- focusable: true,
662
- state: { checked: true },
663
- }),
664
- ],
665
- }),
666
- ],
667
- });
668
- const delta = buildUiDelta(before, after);
669
- const summary = summarizeUiDelta(delta);
670
- expect(delta.updated.some(update => update.after.role === 'checkbox' && update.changes.includes('checked false -> true'))).toBe(true);
671
- expect(summary).toContain('~ n:0.0 checkbox "New York, NY": checked false -> true');
672
- });
673
- it('keeps pinned context nodes and reports viewport/focus/navigation drift', () => {
674
- const before = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
675
- meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 120 },
676
- children: [
677
- node('tablist', 'Application tabs', { x: 16, y: -64, width: 420, height: 40 }, { path: [0] }),
678
- node('form', 'Application', { x: 24, y: -20, width: 760, height: 1400 }, {
679
- path: [1],
680
- children: [
681
- node('textbox', 'Full name', { x: 48, y: 140, width: 320, height: 36 }, {
682
- path: [1, 0],
683
- focusable: true,
684
- state: { focused: true },
685
- }),
686
- ],
687
- }),
688
- ],
689
- });
690
- const after = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
691
- meta: { pageUrl: 'https://jobs.example.com/apply?step=details', scrollX: 0, scrollY: 420 },
692
- children: [
693
- node('tablist', 'Application tabs', { x: 16, y: -96, width: 420, height: 40 }, { path: [0] }),
694
- node('form', 'Application', { x: 24, y: -320, width: 760, height: 1400 }, {
695
- path: [1],
696
- children: [
697
- node('textbox', 'Country', { x: 48, y: 182, width: 320, height: 36 }, {
698
- path: [1, 1],
699
- focusable: true,
700
- state: { focused: true },
701
- }),
702
- ],
703
- }),
704
- ],
705
- });
706
- const compact = buildCompactUiIndex(before, { maxNodes: 20 });
707
- expect(compact.context.pageUrl).toBe('https://jobs.example.com/apply');
708
- expect(compact.context.scrollY).toBe(120);
709
- expect(compact.context.focusedNode?.name).toBe('Full name');
710
- expect(compact.nodes.some(item => item.role === 'tablist' && item.pinned)).toBe(true);
711
- expect(compact.nodes.some(item => item.role === 'form' && item.pinned)).toBe(true);
712
- const delta = buildUiDelta(before, after);
713
- const summary = summarizeUiDelta(delta);
714
- expect(delta.navigation).toEqual({
715
- beforeUrl: 'https://jobs.example.com/apply',
716
- afterUrl: 'https://jobs.example.com/apply?step=details',
717
- });
718
- expect(delta.viewport).toEqual({
719
- beforeScrollX: 0,
720
- beforeScrollY: 120,
721
- afterScrollX: 0,
722
- afterScrollY: 420,
723
- });
724
- expect(delta.focus?.before?.name).toBe('Full name');
725
- expect(delta.focus?.after?.name).toBe('Country');
726
- expect(summary).toContain('~ viewport scroll (0,120) -> (0,420)');
727
- expect(summary).toContain('~ focus n:1.0 textbox "Full name" -> n:1.1 textbox "Country"');
728
- expect(summary).toContain('~ navigation "https://jobs.example.com/apply" -> "https://jobs.example.com/apply?step=details"');
729
- });
730
- it('includes control values in compact indexes and semantic deltas', () => {
731
- const before = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
732
- children: [
733
- node('textbox', 'Location', { x: 20, y: 40, width: 280, height: 36 }, {
734
- path: [0],
735
- focusable: true,
736
- value: 'Austin',
737
- }),
738
- ],
739
- });
740
- const after = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
741
- children: [
742
- node('textbox', 'Location', { x: 20, y: 40, width: 280, height: 36 }, {
743
- path: [0],
744
- focusable: true,
745
- value: 'Austin, Texas, United States',
746
- }),
747
- ],
748
- });
749
- const compact = buildCompactUiIndex(after, { maxNodes: 10 });
750
- expect(compact.nodes[0]).toMatchObject({
751
- role: 'textbox',
752
- name: 'Location',
753
- value: 'Austin, Texas, United States',
754
- });
755
- const delta = buildUiDelta(before, after);
756
- expect(delta.updated).toEqual([
757
- expect.objectContaining({
758
- changes: [
759
- 'value "Austin" -> "Austin, Texas, United States"',
760
- ],
761
- }),
762
- ]);
763
- expect(summarizeUiDelta(delta)).toContain('value "Austin" -> "Austin, Texas, United States"');
764
- });
765
- });
766
- describe('buildA11yTree', () => {
767
- it('maps required, invalid, busy, and validation text from raw semantic nodes', () => {
768
- const tree = {
769
- kind: 'box',
770
- props: {},
771
- semantic: {},
772
- children: [
773
- {
774
- kind: 'box',
775
- props: { value: '' },
776
- semantic: {
777
- role: 'textbox',
778
- ariaLabel: 'Email',
779
- ariaRequired: true,
780
- ariaInvalid: true,
781
- ariaBusy: true,
782
- validationDescription: 'We will contact you about this role.',
783
- validationError: 'Please enter a valid email address.',
784
- },
785
- handlers: { onClick: true, onKeyDown: true },
786
- },
787
- ],
788
- };
789
- const layout = {
790
- x: 0,
791
- y: 0,
792
- width: 800,
793
- height: 600,
794
- children: [
795
- {
796
- x: 24,
797
- y: 40,
798
- width: 320,
799
- height: 36,
800
- children: [],
801
- },
802
- ],
803
- };
804
- const a11y = buildA11yTree(tree, layout);
805
- expect(a11y.children[0]).toMatchObject({
806
- role: 'textbox',
807
- name: 'Email',
808
- state: { required: true, invalid: true, busy: true },
809
- validation: {
810
- description: 'We will contact you about this role.',
811
- error: 'Please enter a valid email address.',
812
- },
813
- });
814
- });
815
- });