@geometra/mcp 1.19.20 → 1.19.23

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.
@@ -17,6 +17,7 @@ const mockState = vi.hoisted(() => ({
17
17
  currentA11yRoot: node('group', undefined, {
18
18
  meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
19
19
  }),
20
+ nodeContexts: new Map(),
20
21
  session: {
21
22
  tree: { kind: 'box' },
22
23
  layout: { x: 0, y: 0, width: 1280, height: 800, children: [] },
@@ -29,6 +30,7 @@ const mockState = vi.hoisted(() => ({
29
30
  formSchemas: [],
30
31
  connect: vi.fn(),
31
32
  connectThroughProxy: vi.fn(),
33
+ prewarmProxy: vi.fn(),
32
34
  sendClick: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
33
35
  sendType: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
34
36
  sendKey: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
@@ -44,13 +46,14 @@ const mockState = vi.hoisted(() => ({
44
46
  sendSelectOption: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
45
47
  sendSetChecked: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
46
48
  sendWheel: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
47
- waitForUiCondition: vi.fn(async () => true),
49
+ waitForUiCondition: vi.fn(async (_session, _check, _timeoutMs) => true),
48
50
  }));
49
51
  function resetMockSessionCaches() {
50
52
  mockState.session.updateRevision = 1;
51
53
  mockState.session.cachedA11y = undefined;
52
54
  mockState.session.cachedA11yRevision = undefined;
53
55
  mockState.session.cachedFormSchemas = undefined;
56
+ mockState.nodeContexts.clear();
54
57
  }
55
58
  function bumpMockUiRevision() {
56
59
  mockState.session.updateRevision += 1;
@@ -61,6 +64,7 @@ function bumpMockUiRevision() {
61
64
  vi.mock('../session.js', () => ({
62
65
  connect: mockState.connect,
63
66
  connectThroughProxy: mockState.connectThroughProxy,
67
+ prewarmProxy: mockState.prewarmProxy,
64
68
  disconnect: vi.fn(),
65
69
  getSession: vi.fn(() => mockState.session),
66
70
  sendClick: mockState.sendClick,
@@ -87,6 +91,7 @@ vi.mock('../session.js', () => ({
87
91
  lists: [],
88
92
  })),
89
93
  buildFormSchemas: vi.fn(() => mockState.formSchemas),
94
+ buildFormRequiredSnapshot: vi.fn(() => []),
90
95
  expandPageSection: vi.fn(() => null),
91
96
  buildUiDelta: vi.fn(() => ({})),
92
97
  hasUiDelta: vi.fn(() => false),
@@ -94,6 +99,7 @@ vi.mock('../session.js', () => ({
94
99
  summarizeCompactIndex: vi.fn(() => ''),
95
100
  summarizePageModel: vi.fn(() => ''),
96
101
  summarizeUiDelta: vi.fn(() => ''),
102
+ nodeContextForNode: vi.fn((_, node) => mockState.nodeContexts.get((node.path ?? []).join('.'))),
97
103
  waitForUiCondition: mockState.waitForUiCondition,
98
104
  }));
99
105
  const { createServer } = await import('../server.js');
@@ -107,6 +113,16 @@ describe('batch MCP result shaping', () => {
107
113
  resetMockSessionCaches();
108
114
  mockState.connect.mockResolvedValue(mockState.session);
109
115
  mockState.connectThroughProxy.mockResolvedValue(mockState.session);
116
+ mockState.prewarmProxy.mockResolvedValue({
117
+ prepared: true,
118
+ reused: false,
119
+ transport: 'embedded',
120
+ pageUrl: 'https://jobs.example.com/application',
121
+ wsUrl: 'ws://127.0.0.1:3200',
122
+ headless: true,
123
+ width: 1280,
124
+ height: 720,
125
+ });
110
126
  mockState.formSchemas = [];
111
127
  mockState.currentA11yRoot = node('group', undefined, {
112
128
  meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
@@ -171,6 +187,42 @@ describe('batch MCP result shaping', () => {
171
187
  readback: { role: 'textbox', value: 'taylor@example.com' },
172
188
  });
173
189
  });
190
+ it('resolves fieldId-only fill_fields entries from the current form schema', async () => {
191
+ const handler = getToolHandler('geometra_fill_fields');
192
+ mockState.formSchemas = [{
193
+ formId: 'fm:0',
194
+ name: 'Application',
195
+ fieldCount: 3,
196
+ requiredCount: 0,
197
+ invalidCount: 0,
198
+ fields: [
199
+ { id: 'ff:0.0', kind: 'text', label: 'Full name' },
200
+ { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', choiceType: 'select' },
201
+ { id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
202
+ ],
203
+ }];
204
+ const result = await handler({
205
+ fields: [
206
+ { kind: 'text', fieldId: 'ff:0.0', value: 'Taylor Applicant' },
207
+ { kind: 'choice', fieldId: 'ff:0.1', value: 'Berlin, Germany' },
208
+ { kind: 'toggle', fieldId: 'ff:0.2', checked: true },
209
+ ],
210
+ stopOnError: true,
211
+ failOnInvalid: false,
212
+ includeSteps: true,
213
+ detail: 'minimal',
214
+ });
215
+ const payload = JSON.parse(result.content[0].text);
216
+ expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Full name', 'Taylor Applicant', { exact: undefined, fieldId: 'ff:0.0' }, undefined);
217
+ expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Preferred location', 'Berlin, Germany', { exact: undefined, query: undefined, choiceType: 'select', fieldId: 'ff:0.1' }, undefined);
218
+ expect(mockState.sendSetChecked).toHaveBeenCalledWith(mockState.session, 'Share my profile for future roles', { checked: true, exact: undefined, controlType: 'checkbox' }, undefined);
219
+ expect(payload).toMatchObject({
220
+ completed: true,
221
+ fieldCount: 3,
222
+ successCount: 3,
223
+ errorCount: 0,
224
+ });
225
+ });
174
226
  it('lets run_actions omit step listings while keeping capped final validation state', async () => {
175
227
  const handler = getToolHandler('geometra_run_actions');
176
228
  mockState.currentA11yRoot = node('group', undefined, {
@@ -232,6 +284,112 @@ describe('batch MCP result shaping', () => {
232
284
  expect(final.invalidFields.length).toBe(4);
233
285
  expect(final.alerts.length).toBe(1);
234
286
  });
287
+ it('uses the proxy batch path for fill_fields when step output is omitted', async () => {
288
+ const handler = getToolHandler('geometra_fill_fields');
289
+ mockState.currentA11yRoot = node('group', undefined, {
290
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
291
+ children: [
292
+ node('textbox', 'Full name', { value: 'Taylor Applicant', path: [0] }),
293
+ node('textbox', 'Email', { value: 'taylor@example.com', path: [1] }),
294
+ ],
295
+ });
296
+ const result = await handler({
297
+ fields: [
298
+ { kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
299
+ { kind: 'text', fieldLabel: 'Email', value: 'taylor@example.com' },
300
+ ],
301
+ stopOnError: true,
302
+ failOnInvalid: false,
303
+ includeSteps: false,
304
+ detail: 'terse',
305
+ });
306
+ const payload = JSON.parse(result.content[0].text);
307
+ expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
308
+ { kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
309
+ { kind: 'text', fieldLabel: 'Email', value: 'taylor@example.com' },
310
+ ]);
311
+ expect(mockState.sendFieldText).not.toHaveBeenCalled();
312
+ expect(payload).toMatchObject({
313
+ completed: true,
314
+ execution: 'batched',
315
+ finalSource: 'session',
316
+ fieldCount: 2,
317
+ successCount: 2,
318
+ errorCount: 0,
319
+ final: { invalidCount: 0 },
320
+ });
321
+ });
322
+ it('auto-connects run_actions and supports final-only output', async () => {
323
+ const handler = getToolHandler('geometra_run_actions');
324
+ mockState.currentA11yRoot = node('group', undefined, {
325
+ meta: { pageUrl: 'https://shop.example.com/login', scrollX: 0, scrollY: 0 },
326
+ children: [
327
+ node('textbox', 'Username', { value: 'standard_user', path: [0] }),
328
+ node('textbox', 'Password', { value: 'secret_sauce', path: [1] }),
329
+ ],
330
+ });
331
+ const result = await handler({
332
+ pageUrl: 'https://shop.example.com/login',
333
+ headless: true,
334
+ actions: [
335
+ {
336
+ type: 'fill_fields',
337
+ fields: [
338
+ { kind: 'text', fieldLabel: 'Username', value: 'standard_user' },
339
+ { kind: 'text', fieldLabel: 'Password', value: 'secret_sauce' },
340
+ ],
341
+ },
342
+ ],
343
+ stopOnError: true,
344
+ includeSteps: false,
345
+ output: 'final',
346
+ detail: 'terse',
347
+ });
348
+ const payload = JSON.parse(result.content[0].text);
349
+ expect(mockState.connectThroughProxy).toHaveBeenCalledWith(expect.objectContaining({
350
+ pageUrl: 'https://shop.example.com/login',
351
+ headless: true,
352
+ awaitInitialFrame: false,
353
+ }));
354
+ expect(payload).toMatchObject({
355
+ autoConnected: true,
356
+ transport: 'proxy',
357
+ pageUrl: 'https://shop.example.com/login',
358
+ completed: true,
359
+ final: { invalidCount: 0 },
360
+ });
361
+ expect(payload).not.toHaveProperty('steps');
362
+ expect(payload).not.toHaveProperty('stepCount');
363
+ });
364
+ it('finds repeated actions by itemText in terse mode', async () => {
365
+ const handler = getToolHandler('geometra_find_action');
366
+ mockState.currentA11yRoot = node('group', undefined, {
367
+ bounds: { x: 0, y: 0, width: 1280, height: 720 },
368
+ meta: { pageUrl: 'https://shop.example.com/inventory', scrollX: 0, scrollY: 0 },
369
+ children: [
370
+ node('button', 'Add to cart', { path: [0], bounds: { x: 40, y: 160, width: 120, height: 36 } }),
371
+ node('button', 'Add to cart', { path: [1], bounds: { x: 40, y: 260, width: 120, height: 36 } }),
372
+ ],
373
+ });
374
+ mockState.nodeContexts.set('0', { item: 'Sauce Labs Backpack', section: 'Inventory' });
375
+ mockState.nodeContexts.set('1', { item: 'Sauce Labs Bike Light', section: 'Inventory' });
376
+ const result = await handler({
377
+ name: 'Add to cart',
378
+ itemText: 'Backpack',
379
+ detail: 'terse',
380
+ maxResults: 4,
381
+ });
382
+ const payload = JSON.parse(result.content[0].text);
383
+ const matches = payload.matches;
384
+ expect(payload.matchCount).toBe(1);
385
+ expect(matches[0]).toMatchObject({
386
+ id: 'n:0',
387
+ role: 'button',
388
+ name: 'Add to cart',
389
+ context: { item: 'Sauce Labs Backpack', section: 'Inventory' },
390
+ center: { x: 100, y: 178 },
391
+ });
392
+ });
235
393
  it('returns a compact structured connect payload by default', async () => {
236
394
  const handler = getToolHandler('geometra_connect');
237
395
  const result = await handler({
@@ -247,6 +405,34 @@ describe('batch MCP result shaping', () => {
247
405
  });
248
406
  expect(payload).not.toHaveProperty('currentUi');
249
407
  });
408
+ it('prepares a warm browser without creating an active session', async () => {
409
+ const handler = getToolHandler('geometra_prepare_browser');
410
+ const result = await handler({
411
+ pageUrl: 'https://jobs.example.com/application',
412
+ headless: true,
413
+ width: 1280,
414
+ height: 720,
415
+ });
416
+ const payload = JSON.parse(result.content[0].text);
417
+ expect(mockState.prewarmProxy).toHaveBeenCalledWith({
418
+ pageUrl: 'https://jobs.example.com/application',
419
+ port: undefined,
420
+ headless: true,
421
+ width: 1280,
422
+ height: 720,
423
+ slowMo: undefined,
424
+ });
425
+ expect(payload).toMatchObject({
426
+ prepared: true,
427
+ reused: false,
428
+ transport: 'embedded',
429
+ pageUrl: 'https://jobs.example.com/application',
430
+ headless: true,
431
+ width: 1280,
432
+ height: 720,
433
+ });
434
+ expect(mockState.connectThroughProxy).not.toHaveBeenCalled();
435
+ });
250
436
  it('can inline a packed form schema into connect for the low-turn form path', async () => {
251
437
  const handler = getToolHandler('geometra_connect');
252
438
  mockState.formSchemas = [
@@ -316,6 +502,72 @@ describe('batch MCP result shaping', () => {
316
502
  });
317
503
  expect(payload).not.toHaveProperty('formSchema');
318
504
  });
505
+ it('can defer the page model so connect returns before the first frame', async () => {
506
+ const handler = getToolHandler('geometra_connect');
507
+ mockState.session.tree = null;
508
+ mockState.session.layout = null;
509
+ const result = await handler({
510
+ pageUrl: 'https://jobs.example.com/application',
511
+ headless: true,
512
+ returnPageModel: true,
513
+ pageModelMode: 'deferred',
514
+ maxPrimaryActions: 4,
515
+ maxSectionsPerKind: 3,
516
+ });
517
+ const payload = JSON.parse(result.content[0].text);
518
+ expect(mockState.connectThroughProxy).toHaveBeenCalledWith({
519
+ pageUrl: 'https://jobs.example.com/application',
520
+ port: undefined,
521
+ headless: true,
522
+ width: undefined,
523
+ height: undefined,
524
+ slowMo: undefined,
525
+ awaitInitialFrame: false,
526
+ eagerInitialExtract: true,
527
+ });
528
+ expect(payload).toMatchObject({
529
+ connected: true,
530
+ transport: 'proxy',
531
+ pageUrl: 'https://jobs.example.com/application',
532
+ pageModel: {
533
+ deferred: true,
534
+ ready: false,
535
+ tool: 'geometra_page_model',
536
+ options: {
537
+ maxPrimaryActions: 4,
538
+ maxSectionsPerKind: 3,
539
+ },
540
+ },
541
+ });
542
+ });
543
+ it('waits for the initial tree when page_model is requested after a deferred connect', async () => {
544
+ const handler = getToolHandler('geometra_page_model');
545
+ mockState.session.tree = null;
546
+ mockState.session.layout = null;
547
+ mockState.waitForUiCondition.mockImplementationOnce(async (_session, check) => {
548
+ mockState.session.tree = { kind: 'box' };
549
+ mockState.session.layout = {
550
+ x: 0,
551
+ y: 0,
552
+ width: 1280,
553
+ height: 800,
554
+ children: [],
555
+ };
556
+ bumpMockUiRevision();
557
+ return check();
558
+ });
559
+ const result = await handler({
560
+ maxPrimaryActions: 4,
561
+ maxSectionsPerKind: 3,
562
+ });
563
+ const payload = JSON.parse(result.content[0].text);
564
+ expect(mockState.waitForUiCondition).toHaveBeenCalledWith(mockState.session, expect.any(Function), 4000);
565
+ expect(payload).toMatchObject({
566
+ viewport: { width: 1280, height: 800 },
567
+ archetypes: ['form'],
568
+ summary: { formCount: 1 },
569
+ });
570
+ });
319
571
  it('returns compact form schemas without requiring section expansion', async () => {
320
572
  const handler = getToolHandler('geometra_form_schema');
321
573
  mockState.formSchemas = [
@@ -633,6 +885,14 @@ describe('query and reveal tools', () => {
633
885
  }),
634
886
  ],
635
887
  });
888
+ mockState.nodeContexts.set('0.0.1', {
889
+ prompt: 'Are you legally authorized to work here?',
890
+ section: 'Application',
891
+ });
892
+ mockState.nodeContexts.set('0.1.1', {
893
+ prompt: 'Will you require sponsorship?',
894
+ section: 'Application',
895
+ });
636
896
  const result = await handler({
637
897
  role: 'button',
638
898
  name: 'Yes',
@@ -763,6 +1023,52 @@ describe('query and reveal tools', () => {
763
1023
  },
764
1024
  });
765
1025
  });
1026
+ it('auto-scales reveal steps for tall forms when maxSteps is omitted', async () => {
1027
+ const handler = getToolHandler('geometra_reveal');
1028
+ let scrollY = 0;
1029
+ const setTree = () => {
1030
+ mockState.currentA11yRoot = node('group', undefined, {
1031
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
1032
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY },
1033
+ children: [
1034
+ node('form', 'Application', {
1035
+ bounds: { x: 20, y: -200 - scrollY, width: 760, height: 6200 },
1036
+ path: [0],
1037
+ children: [
1038
+ node('button', 'Submit application', {
1039
+ bounds: { x: 60, y: 5200 - scrollY, width: 180, height: 40 },
1040
+ path: [0, 0],
1041
+ }),
1042
+ ],
1043
+ }),
1044
+ ],
1045
+ });
1046
+ };
1047
+ setTree();
1048
+ mockState.sendWheel.mockImplementation(async () => {
1049
+ scrollY += 650;
1050
+ setTree();
1051
+ bumpMockUiRevision();
1052
+ return { status: 'updated', timeoutMs: 2500 };
1053
+ });
1054
+ const result = await handler({
1055
+ role: 'button',
1056
+ name: 'Submit application',
1057
+ fullyVisible: true,
1058
+ timeoutMs: 2500,
1059
+ });
1060
+ const payload = JSON.parse(result.content[0].text);
1061
+ expect(mockState.sendWheel).toHaveBeenCalledTimes(7);
1062
+ expect(payload).toMatchObject({
1063
+ revealed: true,
1064
+ attempts: 7,
1065
+ target: {
1066
+ role: 'button',
1067
+ name: 'Submit application',
1068
+ visibility: { fullyVisible: true },
1069
+ },
1070
+ });
1071
+ });
766
1072
  it('clicks an offscreen semantic target by revealing it first', async () => {
767
1073
  const handler = getToolHandler('geometra_click');
768
1074
  mockState.currentA11yRoot = node('group', undefined, {
@@ -855,6 +1161,41 @@ describe('query and reveal tools', () => {
855
1161
  expect(result.content[0].text).toContain('Post-click condition satisfied after');
856
1162
  expect(result.content[0].text).toContain('1 matching node(s).');
857
1163
  });
1164
+ it('waits for the initial tree before a semantic click after deferred connect', async () => {
1165
+ const handler = getToolHandler('geometra_click');
1166
+ mockState.session.tree = null;
1167
+ mockState.session.layout = null;
1168
+ mockState.currentA11yRoot = node('group', undefined, {
1169
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
1170
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1171
+ children: [
1172
+ node('button', 'Open incident', {
1173
+ bounds: { x: 40, y: 120, width: 140, height: 40 },
1174
+ path: [0],
1175
+ }),
1176
+ ],
1177
+ });
1178
+ mockState.waitForUiCondition.mockImplementationOnce(async (_session, check) => {
1179
+ mockState.session.tree = { kind: 'box' };
1180
+ mockState.session.layout = {
1181
+ x: 0,
1182
+ y: 0,
1183
+ width: 1280,
1184
+ height: 800,
1185
+ children: [],
1186
+ };
1187
+ bumpMockUiRevision();
1188
+ return check();
1189
+ });
1190
+ const result = await handler({
1191
+ role: 'button',
1192
+ name: 'Open incident',
1193
+ detail: 'minimal',
1194
+ });
1195
+ expect(mockState.waitForUiCondition).toHaveBeenCalledWith(mockState.session, expect.any(Function), 4000);
1196
+ expect(mockState.sendClick).toHaveBeenCalledWith(mockState.session, 110, 140, undefined);
1197
+ expect(result.content[0].text).toContain('Clicked button "Open incident" (n:0) at (110, 140).');
1198
+ });
858
1199
  it('lets run_actions click a semantic target without manual coordinates', async () => {
859
1200
  const handler = getToolHandler('geometra_run_actions');
860
1201
  mockState.currentA11yRoot = node('group', undefined, {
@@ -915,6 +1256,7 @@ describe('query and reveal tools', () => {
915
1256
  index: 0,
916
1257
  type: 'click',
917
1258
  ok: true,
1259
+ elapsedMs: expect.any(Number),
918
1260
  at: { x: 150, y: 340 },
919
1261
  revealSteps: 1,
920
1262
  target: { id: 'n:0.0', role: 'button', name: 'Submit application' },
@@ -974,6 +1316,7 @@ describe('query and reveal tools', () => {
974
1316
  index: 0,
975
1317
  type: 'click',
976
1318
  ok: true,
1319
+ elapsedMs: expect.any(Number),
977
1320
  postWait: {
978
1321
  present: true,
979
1322
  matchCount: 1,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { buildA11yTree, buildCompactUiIndex, buildFormSchemas, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
2
+ import { buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildFormSchemas, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
3
3
  function node(role, name, bounds, options) {
4
4
  return {
5
5
  role,
@@ -75,6 +75,54 @@ describe('buildPageModel', () => {
75
75
  }),
76
76
  ]);
77
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
+ });
78
126
  it('expands a section by id on demand', () => {
79
127
  const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
80
128
  children: [
@@ -375,6 +423,78 @@ describe('buildFormSchemas', () => {
375
423
  });
376
424
  });
377
425
  });
426
+ describe('buildFormRequiredSnapshot', () => {
427
+ it('keeps offscreen required fields with bounds, visibility, and scroll hints', () => {
428
+ const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
429
+ children: [
430
+ node('form', 'Application', { x: 20, y: -160, width: 760, height: 2200 }, {
431
+ path: [0],
432
+ children: [
433
+ node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
434
+ path: [0, 0],
435
+ state: { required: true },
436
+ }),
437
+ node('combobox', 'Preferred location', { x: 48, y: 940, width: 320, height: 36 }, {
438
+ path: [0, 1],
439
+ state: { required: true },
440
+ meta: { controlTag: 'select' },
441
+ }),
442
+ node('group', undefined, { x: 40, y: 1260, width: 520, height: 96 }, {
443
+ path: [0, 2],
444
+ children: [
445
+ node('text', 'Will you require sponsorship?', { x: 48, y: 1260, width: 320, height: 24 }, {
446
+ path: [0, 2, 0],
447
+ }),
448
+ node('button', 'Yes', { x: 48, y: 1300, width: 88, height: 40 }, {
449
+ path: [0, 2, 1],
450
+ focusable: true,
451
+ state: { required: true },
452
+ }),
453
+ node('button', 'No', { x: 148, y: 1300, width: 88, height: 40 }, {
454
+ path: [0, 2, 2],
455
+ focusable: true,
456
+ }),
457
+ ],
458
+ }),
459
+ ],
460
+ }),
461
+ ],
462
+ });
463
+ const snapshot = buildFormRequiredSnapshot(tree, { includeOptions: true });
464
+ expect(snapshot).toHaveLength(1);
465
+ expect(snapshot[0]).toMatchObject({
466
+ formId: 'fm:0',
467
+ name: 'Application',
468
+ requiredCount: 3,
469
+ fields: [
470
+ {
471
+ id: 'ff:0.0',
472
+ label: 'Full name',
473
+ visibility: { intersectsViewport: true, fullyVisible: true },
474
+ scrollHint: { status: 'visible' },
475
+ bounds: { x: 48, y: 120, width: 320, height: 36 },
476
+ },
477
+ {
478
+ id: 'ff:0.1',
479
+ label: 'Preferred location',
480
+ choiceType: 'select',
481
+ visibility: { intersectsViewport: false, offscreenBelow: true },
482
+ scrollHint: { status: 'offscreen' },
483
+ bounds: { x: 48, y: 940, width: 320, height: 36 },
484
+ },
485
+ {
486
+ id: 'ff:0.2',
487
+ label: 'Will you require sponsorship?',
488
+ choiceType: 'group',
489
+ visibility: { intersectsViewport: false, offscreenBelow: true },
490
+ scrollHint: { status: 'offscreen' },
491
+ bounds: { x: 40, y: 1260, width: 520, height: 96 },
492
+ options: ['Yes', 'No'],
493
+ },
494
+ ],
495
+ });
496
+ });
497
+ });
378
498
  describe('buildUiDelta', () => {
379
499
  it('captures opened dialogs, state changes, and list count changes', () => {
380
500
  const before = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
@@ -1,6 +1,8 @@
1
1
  import { type ChildProcess } from 'node:child_process';
2
2
  export interface EmbeddedProxyRuntime {
3
3
  wsUrl: string;
4
+ ready: Promise<void>;
5
+ getTrace?: () => Record<string, unknown>;
4
6
  closed: boolean;
5
7
  close: () => Promise<void>;
6
8
  }
@@ -16,6 +18,7 @@ export interface SpawnProxyParams {
16
18
  width?: number;
17
19
  height?: number;
18
20
  slowMo?: number;
21
+ eagerInitialExtract?: boolean;
19
22
  }
20
23
  export declare function startEmbeddedGeometraProxy(opts: SpawnProxyParams): Promise<{
21
24
  runtime: EmbeddedProxyRuntime;
@@ -138,6 +138,7 @@ export async function startEmbeddedGeometraProxy(opts) {
138
138
  height: opts.height,
139
139
  headed: opts.headless !== true,
140
140
  slowMo: opts.slowMo,
141
+ eagerInitialExtract: opts.eagerInitialExtract,
141
142
  });
142
143
  return { runtime, wsUrl: runtime.wsUrl };
143
144
  }
@@ -189,6 +190,8 @@ export function spawnGeometraProxy(opts) {
189
190
  args.push('--headless');
190
191
  else if (opts.headless === false)
191
192
  args.push('--headed');
193
+ if (opts.eagerInitialExtract === false)
194
+ args.push('--lazy-initial-extract');
192
195
  return new Promise((resolve, reject) => {
193
196
  const child = spawn(process.execPath, args, {
194
197
  stdio: ['ignore', 'pipe', 'pipe'],
package/dist/server.d.ts CHANGED
@@ -7,6 +7,9 @@ interface NodeFilter {
7
7
  name?: string;
8
8
  text?: string;
9
9
  contextText?: string;
10
+ promptText?: string;
11
+ sectionText?: string;
12
+ itemText?: string;
10
13
  value?: string;
11
14
  checked?: NodeStateFilterValue;
12
15
  disabled?: boolean;