@geometra/mcp 1.59.1 → 1.60.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,1777 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- function node(role, name, options) {
3
- return {
4
- role,
5
- ...(name ? { name } : {}),
6
- ...(options?.value ? { value: options.value } : {}),
7
- ...(options?.state ? { state: options.state } : {}),
8
- ...(options?.validation ? { validation: options.validation } : {}),
9
- ...(options?.meta ? { meta: options.meta } : {}),
10
- bounds: options?.bounds ?? { x: 0, y: 0, width: 120, height: 40 },
11
- path: options?.path ?? [],
12
- children: options?.children ?? [],
13
- focusable: role !== 'group',
14
- };
15
- }
16
- const mockState = vi.hoisted(() => ({
17
- currentA11yRoot: node('group', undefined, {
18
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
19
- }),
20
- nodeContexts: new Map(),
21
- session: {
22
- tree: { kind: 'box' },
23
- layout: { x: 0, y: 0, width: 1280, height: 800, children: [] },
24
- url: 'ws://127.0.0.1:3200',
25
- updateRevision: 1,
26
- cachedA11y: undefined,
27
- cachedA11yRevision: undefined,
28
- cachedFormSchemas: undefined,
29
- },
30
- formSchemas: [],
31
- connect: vi.fn(),
32
- connectThroughProxy: vi.fn(),
33
- prewarmProxy: vi.fn(),
34
- sendClick: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
35
- sendType: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
36
- sendKey: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
37
- sendFileUpload: vi.fn(async () => ({ status: 'updated', timeoutMs: 8000 })),
38
- sendFieldText: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
39
- sendFieldChoice: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
40
- sendFillFields: vi.fn(async () => ({
41
- status: 'updated',
42
- timeoutMs: 6000,
43
- result: undefined,
44
- })),
45
- sendListboxPick: vi.fn(async () => ({ status: 'updated', timeoutMs: 4500 })),
46
- sendSelectOption: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
47
- sendSetChecked: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
48
- sendWheel: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
49
- waitForUiCondition: vi.fn(async (_session, _check, _timeoutMs) => true),
50
- expandPageSection: vi.fn((_root, _id, _opts) => null),
51
- }));
52
- function resetMockSessionCaches() {
53
- mockState.session.updateRevision = 1;
54
- mockState.session.cachedA11y = undefined;
55
- mockState.session.cachedA11yRevision = undefined;
56
- mockState.session.cachedFormSchemas = undefined;
57
- mockState.nodeContexts.clear();
58
- }
59
- function bumpMockUiRevision() {
60
- mockState.session.updateRevision += 1;
61
- mockState.session.cachedA11y = undefined;
62
- mockState.session.cachedA11yRevision = undefined;
63
- mockState.session.cachedFormSchemas = undefined;
64
- }
65
- vi.mock('../session.js', () => ({
66
- connect: mockState.connect,
67
- connectThroughProxy: mockState.connectThroughProxy,
68
- prewarmProxy: mockState.prewarmProxy,
69
- disconnect: vi.fn(),
70
- pruneDisconnectedSessions: vi.fn(() => []),
71
- getSession: vi.fn(() => mockState.session),
72
- resolveSession: vi.fn((id) => ({ kind: 'ok', session: mockState.session, ...(id ? { id } : {}) })),
73
- listSessions: vi.fn(() => [{ id: 's1', url: 'https://jobs.example.com/application' }]),
74
- getDefaultSessionId: vi.fn(() => 's1'),
75
- sendClick: mockState.sendClick,
76
- sendType: mockState.sendType,
77
- sendKey: mockState.sendKey,
78
- sendFileUpload: mockState.sendFileUpload,
79
- sendFieldText: mockState.sendFieldText,
80
- sendFieldChoice: mockState.sendFieldChoice,
81
- sendFillFields: mockState.sendFillFields,
82
- sendFillOtp: vi.fn(async () => ({ status: 'updated', timeoutMs: 5000, result: { cellCount: 6, filledCount: 6 } })),
83
- sendListboxPick: mockState.sendListboxPick,
84
- sendSelectOption: mockState.sendSelectOption,
85
- sendSetChecked: mockState.sendSetChecked,
86
- sendWheel: mockState.sendWheel,
87
- buildA11yTree: vi.fn(() => mockState.currentA11yRoot),
88
- buildCompactUiIndex: vi.fn(() => ({ nodes: [], context: {} })),
89
- buildPageModel: vi.fn(() => ({
90
- viewport: { width: 1280, height: 800 },
91
- archetypes: ['form'],
92
- summary: { landmarkCount: 0, formCount: 1, dialogCount: 0, listCount: 0, focusableCount: 2 },
93
- primaryActions: [],
94
- landmarks: [],
95
- forms: [],
96
- dialogs: [],
97
- lists: [],
98
- })),
99
- buildFormSchemas: vi.fn(() => mockState.formSchemas),
100
- buildFormRequiredSnapshot: vi.fn(() => []),
101
- expandPageSection: mockState.expandPageSection,
102
- buildUiDelta: vi.fn(() => ({})),
103
- hasUiDelta: vi.fn(() => false),
104
- nodeIdForPath: vi.fn((path) => `n:${path.length > 0 ? path.join('.') : 'root'}`),
105
- summarizeCompactIndex: vi.fn(() => ''),
106
- summarizePageModel: vi.fn(() => ''),
107
- summarizeUiDelta: vi.fn(() => ''),
108
- nodeContextForNode: vi.fn((_, node) => mockState.nodeContexts.get((node.path ?? []).join('.'))),
109
- waitForUiCondition: mockState.waitForUiCondition,
110
- }));
111
- const { createServer } = await import('../server.js');
112
- function getToolHandler(name) {
113
- const server = createServer();
114
- return server._registeredTools[name].handler;
115
- }
116
- describe('batch MCP result shaping', () => {
117
- beforeEach(() => {
118
- vi.clearAllMocks();
119
- resetMockSessionCaches();
120
- mockState.connect.mockResolvedValue(mockState.session);
121
- mockState.connectThroughProxy.mockResolvedValue(mockState.session);
122
- mockState.prewarmProxy.mockResolvedValue({
123
- prepared: true,
124
- reused: false,
125
- transport: 'embedded',
126
- pageUrl: 'https://jobs.example.com/application',
127
- wsUrl: 'ws://127.0.0.1:3200',
128
- headless: true,
129
- width: 1280,
130
- height: 720,
131
- });
132
- mockState.formSchemas = [];
133
- mockState.currentA11yRoot = node('group', undefined, {
134
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
135
- children: [
136
- node('textbox', 'Mission', {
137
- value: 'Ship calm developer tools across browsers and platforms.',
138
- path: [0],
139
- }),
140
- node('textbox', 'Email', {
141
- value: 'taylor@example.com',
142
- path: [1],
143
- }),
144
- ],
145
- });
146
- });
147
- it('keeps fill_fields minimal output structured and does not echo long essay text', async () => {
148
- const longAnswer = 'A'.repeat(180);
149
- const handler = getToolHandler('geometra_fill_fields');
150
- mockState.currentA11yRoot = node('group', undefined, {
151
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
152
- children: [
153
- node('textbox', 'Mission', { value: longAnswer, path: [0] }),
154
- node('textbox', 'Email', { value: 'taylor@example.com', path: [1] }),
155
- ],
156
- });
157
- const result = await handler({
158
- fields: [
159
- { kind: 'text', fieldLabel: 'Mission', value: longAnswer },
160
- { kind: 'text', fieldLabel: 'Email', value: 'taylor@example.com' },
161
- ],
162
- stopOnError: true,
163
- failOnInvalid: false,
164
- includeSteps: true,
165
- detail: 'minimal',
166
- });
167
- const text = result.content[0].text;
168
- const payload = JSON.parse(text);
169
- const steps = payload.steps;
170
- expect(text).not.toContain(longAnswer);
171
- expect(payload).toMatchObject({
172
- completed: true,
173
- fieldCount: 2,
174
- successCount: 2,
175
- errorCount: 0,
176
- });
177
- expect(steps[0]).toMatchObject({
178
- index: 0,
179
- kind: 'text',
180
- ok: true,
181
- fieldLabel: 'Mission',
182
- valueLength: 180,
183
- wait: 'updated',
184
- readback: { role: 'textbox', valueLength: 180 },
185
- });
186
- expect(steps[1]).toMatchObject({
187
- index: 1,
188
- kind: 'text',
189
- ok: true,
190
- fieldLabel: 'Email',
191
- value: 'taylor@example.com',
192
- wait: 'updated',
193
- readback: { role: 'textbox', value: 'taylor@example.com' },
194
- });
195
- });
196
- it('resolves fieldId-only fill_fields entries from the current form schema', async () => {
197
- const handler = getToolHandler('geometra_fill_fields');
198
- mockState.formSchemas = [{
199
- formId: 'fm:0',
200
- name: 'Application',
201
- fieldCount: 3,
202
- requiredCount: 0,
203
- invalidCount: 0,
204
- fields: [
205
- { id: 'ff:0.0', kind: 'text', label: 'Full name' },
206
- { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', choiceType: 'select' },
207
- { id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
208
- ],
209
- }];
210
- const result = await handler({
211
- fields: [
212
- { kind: 'text', fieldId: 'ff:0.0', value: 'Taylor Applicant' },
213
- { kind: 'choice', fieldId: 'ff:0.1', value: 'Berlin, Germany' },
214
- { kind: 'toggle', fieldId: 'ff:0.2', checked: true },
215
- ],
216
- stopOnError: true,
217
- failOnInvalid: false,
218
- includeSteps: true,
219
- detail: 'minimal',
220
- });
221
- const payload = JSON.parse(result.content[0].text);
222
- expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Full name', 'Taylor Applicant', { exact: undefined, fieldId: 'ff:0.0' }, undefined);
223
- expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Preferred location', 'Berlin, Germany', { exact: undefined, query: undefined, choiceType: 'select', fieldId: 'ff:0.1' }, undefined);
224
- expect(mockState.sendSetChecked).toHaveBeenCalledWith(mockState.session, 'Share my profile for future roles', { checked: true, exact: undefined, controlType: 'checkbox' }, undefined);
225
- expect(payload).toMatchObject({
226
- completed: true,
227
- fieldCount: 3,
228
- successCount: 3,
229
- errorCount: 0,
230
- });
231
- });
232
- it('lets run_actions omit step listings while keeping capped final validation state', async () => {
233
- const handler = getToolHandler('geometra_run_actions');
234
- mockState.currentA11yRoot = node('group', undefined, {
235
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 2400 },
236
- children: [
237
- node('textbox', 'Full name', {
238
- value: '',
239
- path: [0],
240
- state: { invalid: true, required: true },
241
- validation: { error: 'Enter your full name.' },
242
- }),
243
- node('textbox', 'Email', {
244
- value: '',
245
- path: [1],
246
- state: { invalid: true, required: true },
247
- validation: { error: 'Enter your email.' },
248
- }),
249
- node('textbox', 'Phone', {
250
- value: '',
251
- path: [2],
252
- state: { invalid: true, required: true },
253
- validation: { error: 'Enter your phone number.' },
254
- }),
255
- node('textbox', 'Location', {
256
- value: '',
257
- path: [3],
258
- state: { invalid: true, required: true },
259
- validation: { error: 'Choose a location.' },
260
- }),
261
- node('textbox', 'LinkedIn', {
262
- value: '',
263
- path: [4],
264
- state: { invalid: true },
265
- validation: { error: 'Enter a valid URL.' },
266
- }),
267
- node('alert', 'Your form needs corrections', { path: [5] }),
268
- ],
269
- });
270
- const result = await handler({
271
- actions: [{ type: 'click', x: 320, y: 540 }],
272
- stopOnError: true,
273
- includeSteps: false,
274
- detail: 'minimal',
275
- });
276
- const payload = JSON.parse(result.content[0].text);
277
- const final = payload.final;
278
- expect(payload).toMatchObject({
279
- completed: true,
280
- stepCount: 1,
281
- successCount: 1,
282
- errorCount: 0,
283
- });
284
- expect(payload).not.toHaveProperty('steps');
285
- expect(final).toMatchObject({
286
- pageUrl: 'https://jobs.example.com/application',
287
- alertCount: 1,
288
- invalidCount: 5,
289
- });
290
- expect(final.invalidFields.length).toBe(5);
291
- expect(final.alerts.length).toBe(1);
292
- });
293
- it('uses the proxy batch path for fill_fields when step output is omitted', async () => {
294
- const handler = getToolHandler('geometra_fill_fields');
295
- mockState.currentA11yRoot = node('group', undefined, {
296
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
297
- children: [
298
- node('textbox', 'Full name', { value: 'Taylor Applicant', path: [0] }),
299
- node('textbox', 'Email', { value: 'taylor@example.com', path: [1] }),
300
- ],
301
- });
302
- const result = await handler({
303
- fields: [
304
- { kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
305
- { kind: 'text', fieldLabel: 'Email', value: 'taylor@example.com' },
306
- ],
307
- stopOnError: true,
308
- failOnInvalid: false,
309
- includeSteps: false,
310
- detail: 'terse',
311
- });
312
- const payload = JSON.parse(result.content[0].text);
313
- expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
314
- { kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
315
- { kind: 'text', fieldLabel: 'Email', value: 'taylor@example.com' },
316
- ]);
317
- expect(mockState.sendFieldText).not.toHaveBeenCalled();
318
- expect(payload).toMatchObject({
319
- completed: true,
320
- execution: 'batched',
321
- finalSource: 'session',
322
- fieldCount: 2,
323
- successCount: 2,
324
- errorCount: 0,
325
- final: { invalidCount: 0 },
326
- });
327
- });
328
- it('auto-connects run_actions and supports final-only output', async () => {
329
- const handler = getToolHandler('geometra_run_actions');
330
- mockState.currentA11yRoot = node('group', undefined, {
331
- meta: { pageUrl: 'https://shop.example.com/login', scrollX: 0, scrollY: 0 },
332
- children: [
333
- node('textbox', 'Username', { value: 'standard_user', path: [0] }),
334
- node('textbox', 'Password', { value: 'secret_sauce', path: [1] }),
335
- ],
336
- });
337
- const result = await handler({
338
- pageUrl: 'https://shop.example.com/login',
339
- headless: true,
340
- actions: [
341
- {
342
- type: 'fill_fields',
343
- fields: [
344
- { kind: 'text', fieldLabel: 'Username', value: 'standard_user' },
345
- { kind: 'text', fieldLabel: 'Password', value: 'secret_sauce' },
346
- ],
347
- },
348
- ],
349
- stopOnError: true,
350
- includeSteps: false,
351
- output: 'final',
352
- detail: 'terse',
353
- });
354
- const payload = JSON.parse(result.content[0].text);
355
- expect(mockState.connectThroughProxy).toHaveBeenCalledWith(expect.objectContaining({
356
- pageUrl: 'https://shop.example.com/login',
357
- headless: true,
358
- awaitInitialFrame: false,
359
- }));
360
- expect(payload).toMatchObject({
361
- autoConnected: true,
362
- transport: 'proxy',
363
- pageUrl: 'https://shop.example.com/login',
364
- completed: true,
365
- final: { invalidCount: 0 },
366
- });
367
- expect(payload).not.toHaveProperty('steps');
368
- expect(payload).not.toHaveProperty('stepCount');
369
- });
370
- it('attaches verifyFills readback results to run_actions fill_fields step', async () => {
371
- const handler = getToolHandler('geometra_run_actions');
372
- mockState.currentA11yRoot = node('group', undefined, {
373
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
374
- children: [
375
- node('textbox', 'Full name', { value: 'Taylor Applicant', path: [0] }),
376
- node('textbox', 'Phone', { value: '(929) 608-1737', path: [1] }),
377
- ],
378
- });
379
- const result = await handler({
380
- pageUrl: 'https://jobs.example.com/application',
381
- headless: true,
382
- actions: [
383
- {
384
- type: 'fill_fields',
385
- verifyFills: true,
386
- fields: [
387
- { kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
388
- { kind: 'text', fieldLabel: 'Phone', value: '+1-929-608-1737' },
389
- ],
390
- },
391
- ],
392
- stopOnError: true,
393
- includeSteps: true,
394
- detail: 'terse',
395
- });
396
- const payload = JSON.parse(result.content[0].text);
397
- const steps = payload.steps;
398
- expect(steps).toHaveLength(1);
399
- expect(steps[0]).toMatchObject({
400
- type: 'fill_fields',
401
- ok: true,
402
- fieldCount: 2,
403
- verification: { verified: 2, mismatches: [] },
404
- });
405
- });
406
- it('runs expand_section inside run_actions and returns the section detail inline', async () => {
407
- const handler = getToolHandler('geometra_run_actions');
408
- const fakeDetail = {
409
- id: 'fm:1.0',
410
- kind: 'form',
411
- name: 'Application',
412
- fieldCount: 3,
413
- fields: [{ id: 'f1', label: 'Full name', required: true }],
414
- };
415
- mockState.expandPageSection.mockReturnValueOnce(fakeDetail);
416
- const result = await handler({
417
- pageUrl: 'https://jobs.example.com/application',
418
- headless: true,
419
- actions: [
420
- {
421
- type: 'expand_section',
422
- id: 'fm:1.0',
423
- maxFields: 10,
424
- onlyRequiredFields: true,
425
- },
426
- ],
427
- includeSteps: true,
428
- detail: 'terse',
429
- });
430
- expect(mockState.expandPageSection).toHaveBeenCalledWith(mockState.currentA11yRoot, 'fm:1.0', expect.objectContaining({ maxFields: 10, onlyRequiredFields: true }));
431
- const payload = JSON.parse(result.content[0].text);
432
- const steps = payload.steps;
433
- expect(steps).toHaveLength(1);
434
- expect(steps[0]).toMatchObject({
435
- type: 'expand_section',
436
- ok: true,
437
- id: 'fm:1.0',
438
- detail: fakeDetail,
439
- });
440
- });
441
- it('surfaces an error when expand_section id does not match a section', async () => {
442
- const handler = getToolHandler('geometra_run_actions');
443
- mockState.expandPageSection.mockReturnValueOnce(null);
444
- const result = await handler({
445
- pageUrl: 'https://jobs.example.com/application',
446
- headless: true,
447
- actions: [{ type: 'expand_section', id: 'fm:9.9' }],
448
- includeSteps: true,
449
- stopOnError: false,
450
- detail: 'terse',
451
- });
452
- const payload = JSON.parse(result.content[0].text);
453
- const steps = payload.steps;
454
- expect(steps[0]).toMatchObject({
455
- type: 'expand_section',
456
- ok: false,
457
- error: expect.stringContaining('fm:9.9'),
458
- });
459
- });
460
- it('flags mismatched fields in run_actions verifyFills output', async () => {
461
- const handler = getToolHandler('geometra_run_actions');
462
- mockState.currentA11yRoot = node('group', undefined, {
463
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
464
- children: [
465
- node('textbox', 'Full name', { value: 'Unexpected Name', path: [0] }),
466
- ],
467
- });
468
- const result = await handler({
469
- pageUrl: 'https://jobs.example.com/application',
470
- headless: true,
471
- actions: [
472
- {
473
- type: 'fill_fields',
474
- verifyFills: true,
475
- fields: [
476
- { kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
477
- ],
478
- },
479
- ],
480
- includeSteps: true,
481
- detail: 'terse',
482
- });
483
- const payload = JSON.parse(result.content[0].text);
484
- const steps = payload.steps;
485
- const verification = steps[0].verification;
486
- expect(verification.verified).toBe(0);
487
- expect(verification.mismatches).toHaveLength(1);
488
- expect(verification.mismatches[0]).toMatchObject({
489
- fieldLabel: 'Full name',
490
- expected: 'Taylor Applicant',
491
- actual: 'Unexpected Name',
492
- });
493
- });
494
- it('finds repeated actions by itemText in terse mode', async () => {
495
- const handler = getToolHandler('geometra_find_action');
496
- mockState.currentA11yRoot = node('group', undefined, {
497
- bounds: { x: 0, y: 0, width: 1280, height: 720 },
498
- meta: { pageUrl: 'https://shop.example.com/inventory', scrollX: 0, scrollY: 0 },
499
- children: [
500
- node('button', 'Add to cart', { path: [0], bounds: { x: 40, y: 160, width: 120, height: 36 } }),
501
- node('button', 'Add to cart', { path: [1], bounds: { x: 40, y: 260, width: 120, height: 36 } }),
502
- ],
503
- });
504
- mockState.nodeContexts.set('0', { item: 'Sauce Labs Backpack', section: 'Inventory' });
505
- mockState.nodeContexts.set('1', { item: 'Sauce Labs Bike Light', section: 'Inventory' });
506
- const result = await handler({
507
- name: 'Add to cart',
508
- itemText: 'Backpack',
509
- detail: 'terse',
510
- maxResults: 4,
511
- });
512
- const payload = JSON.parse(result.content[0].text);
513
- const matches = payload.matches;
514
- expect(payload.matchCount).toBe(1);
515
- expect(matches[0]).toMatchObject({
516
- id: 'n:0',
517
- role: 'button',
518
- name: 'Add to cart',
519
- context: { item: 'Sauce Labs Backpack', section: 'Inventory' },
520
- center: { x: 100, y: 178 },
521
- });
522
- });
523
- it('returns a compact structured connect payload by default', async () => {
524
- const handler = getToolHandler('geometra_connect');
525
- const result = await handler({
526
- pageUrl: 'https://jobs.example.com/application',
527
- headless: true,
528
- });
529
- const payload = JSON.parse(result.content[0].text);
530
- expect(payload).toMatchObject({
531
- connected: true,
532
- transport: 'proxy',
533
- wsUrl: 'ws://127.0.0.1:3200',
534
- pageUrl: 'https://jobs.example.com/application',
535
- });
536
- expect(payload).not.toHaveProperty('currentUi');
537
- });
538
- it('prepares a warm browser without creating an active session', async () => {
539
- const handler = getToolHandler('geometra_prepare_browser');
540
- const result = await handler({
541
- pageUrl: 'https://jobs.example.com/application',
542
- headless: true,
543
- width: 1280,
544
- height: 720,
545
- });
546
- const payload = JSON.parse(result.content[0].text);
547
- expect(mockState.prewarmProxy).toHaveBeenCalledWith({
548
- pageUrl: 'https://jobs.example.com/application',
549
- port: undefined,
550
- headless: true,
551
- width: 1280,
552
- height: 720,
553
- slowMo: undefined,
554
- });
555
- expect(payload).toMatchObject({
556
- prepared: true,
557
- reused: false,
558
- transport: 'embedded',
559
- pageUrl: 'https://jobs.example.com/application',
560
- headless: true,
561
- width: 1280,
562
- height: 720,
563
- });
564
- expect(mockState.connectThroughProxy).not.toHaveBeenCalled();
565
- });
566
- it('can inline a packed form schema into connect for the low-turn form path', async () => {
567
- const handler = getToolHandler('geometra_connect');
568
- mockState.formSchemas = [
569
- {
570
- formId: 'fm:0',
571
- name: 'Application',
572
- fieldCount: 2,
573
- requiredCount: 1,
574
- invalidCount: 0,
575
- fields: [
576
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
577
- { id: 'ff:0.1', kind: 'choice', label: 'Work authorization', choiceType: 'group', booleanChoice: true, optionCount: 2 },
578
- ],
579
- },
580
- ];
581
- const result = await handler({
582
- pageUrl: 'https://jobs.example.com/application',
583
- headless: true,
584
- returnForms: true,
585
- includeContext: 'none',
586
- schemaFormat: 'packed',
587
- });
588
- const payload = JSON.parse(result.content[0].text);
589
- expect(payload).toMatchObject({
590
- connected: true,
591
- transport: 'proxy',
592
- pageUrl: 'https://jobs.example.com/application',
593
- formSchema: {
594
- changed: true,
595
- formCount: 1,
596
- format: 'packed',
597
- schemaId: expect.any(String),
598
- forms: [
599
- {
600
- i: 'fm:0',
601
- fc: 2,
602
- rc: 1,
603
- ic: 0,
604
- f: [
605
- { i: 'ff:0.0', k: 'text', l: 'Full name', r: 1 },
606
- { i: 'ff:0.1', k: 'choice', l: 'Work authorization', ch: 'group', b: 1, oc: 2 },
607
- ],
608
- },
609
- ],
610
- },
611
- });
612
- });
613
- it('can inline the page model into connect for the low-turn exploration path', async () => {
614
- const handler = getToolHandler('geometra_connect');
615
- const result = await handler({
616
- pageUrl: 'https://jobs.example.com/application',
617
- headless: true,
618
- returnPageModel: true,
619
- maxPrimaryActions: 4,
620
- maxSectionsPerKind: 3,
621
- });
622
- const payload = JSON.parse(result.content[0].text);
623
- expect(payload).toMatchObject({
624
- connected: true,
625
- transport: 'proxy',
626
- pageUrl: 'https://jobs.example.com/application',
627
- pageModel: {
628
- viewport: { width: 1280, height: 800 },
629
- archetypes: ['form'],
630
- summary: { formCount: 1 },
631
- },
632
- });
633
- expect(payload).not.toHaveProperty('formSchema');
634
- });
635
- it('can defer the page model so connect returns before the first frame', async () => {
636
- const handler = getToolHandler('geometra_connect');
637
- mockState.session.tree = null;
638
- mockState.session.layout = null;
639
- const result = await handler({
640
- pageUrl: 'https://jobs.example.com/application',
641
- headless: true,
642
- returnPageModel: true,
643
- pageModelMode: 'deferred',
644
- maxPrimaryActions: 4,
645
- maxSectionsPerKind: 3,
646
- });
647
- const payload = JSON.parse(result.content[0].text);
648
- expect(mockState.connectThroughProxy).toHaveBeenCalledWith({
649
- pageUrl: 'https://jobs.example.com/application',
650
- port: undefined,
651
- headless: true,
652
- width: undefined,
653
- height: undefined,
654
- slowMo: undefined,
655
- awaitInitialFrame: false,
656
- eagerInitialExtract: true,
657
- });
658
- expect(payload).toMatchObject({
659
- connected: true,
660
- transport: 'proxy',
661
- pageUrl: 'https://jobs.example.com/application',
662
- pageModel: {
663
- deferred: true,
664
- ready: false,
665
- tool: 'geometra_page_model',
666
- options: {
667
- maxPrimaryActions: 4,
668
- maxSectionsPerKind: 3,
669
- },
670
- },
671
- });
672
- });
673
- it('waits for the initial tree when page_model is requested after a deferred connect', async () => {
674
- const handler = getToolHandler('geometra_page_model');
675
- mockState.session.tree = null;
676
- mockState.session.layout = null;
677
- mockState.waitForUiCondition.mockImplementationOnce(async (_session, check) => {
678
- mockState.session.tree = { kind: 'box' };
679
- mockState.session.layout = {
680
- x: 0,
681
- y: 0,
682
- width: 1280,
683
- height: 800,
684
- children: [],
685
- };
686
- bumpMockUiRevision();
687
- return check();
688
- });
689
- const result = await handler({
690
- maxPrimaryActions: 4,
691
- maxSectionsPerKind: 3,
692
- });
693
- const payload = JSON.parse(result.content[0].text);
694
- expect(mockState.waitForUiCondition).toHaveBeenCalledWith(mockState.session, expect.any(Function), 4000);
695
- expect(payload).toMatchObject({
696
- viewport: { width: 1280, height: 800 },
697
- archetypes: ['form'],
698
- summary: { formCount: 1 },
699
- });
700
- });
701
- it('returns compact form schemas without requiring section expansion', async () => {
702
- const handler = getToolHandler('geometra_form_schema');
703
- mockState.formSchemas = [
704
- {
705
- formId: 'fm:0',
706
- name: 'Application',
707
- fieldCount: 4,
708
- requiredCount: 3,
709
- invalidCount: 0,
710
- fields: [
711
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
712
- { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, choiceType: 'select' },
713
- { id: 'ff:0.2', kind: 'choice', label: 'Are you legally authorized to work in Germany?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
714
- { id: 'ff:0.3', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
715
- ],
716
- },
717
- ];
718
- const result = await handler({ maxFields: 20 });
719
- const payload = JSON.parse(result.content[0].text);
720
- expect(payload).toMatchObject({
721
- changed: true,
722
- formCount: 1,
723
- format: 'compact',
724
- schemaId: expect.any(String),
725
- forms: [
726
- expect.objectContaining({
727
- formId: 'fm:0',
728
- fieldCount: 4,
729
- requiredCount: 3,
730
- invalidCount: 0,
731
- }),
732
- ],
733
- });
734
- const forms = payload.forms;
735
- const fields = forms[0]?.fields;
736
- expect(fields[2]).toMatchObject({
737
- id: 'ff:0.2',
738
- kind: 'choice',
739
- label: 'Are you legally authorized to work in Germany?',
740
- choiceType: 'group',
741
- booleanChoice: true,
742
- optionCount: 2,
743
- });
744
- expect(fields[2]).not.toHaveProperty('options');
745
- });
746
- it('can auto-connect inside form_schema when given a pageUrl', async () => {
747
- const handler = getToolHandler('geometra_form_schema');
748
- mockState.formSchemas = [
749
- {
750
- formId: 'fm:0',
751
- name: 'Application',
752
- fieldCount: 1,
753
- requiredCount: 1,
754
- invalidCount: 0,
755
- fields: [{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true }],
756
- },
757
- ];
758
- const result = await handler({
759
- pageUrl: 'https://jobs.example.com/application',
760
- headless: true,
761
- });
762
- const payload = JSON.parse(result.content[0].text);
763
- expect(mockState.connectThroughProxy).toHaveBeenCalledWith({
764
- pageUrl: 'https://jobs.example.com/application',
765
- port: undefined,
766
- headless: true,
767
- width: undefined,
768
- height: undefined,
769
- slowMo: undefined,
770
- awaitInitialFrame: undefined,
771
- });
772
- expect(payload).toMatchObject({
773
- autoConnected: true,
774
- transport: 'proxy',
775
- pageUrl: 'https://jobs.example.com/application',
776
- changed: true,
777
- formCount: 1,
778
- });
779
- });
780
- it('fills a form from ids and labels without echoing long essay content', async () => {
781
- const longAnswer = 'B'.repeat(220);
782
- const handler = getToolHandler('geometra_fill_form');
783
- mockState.formSchemas = [
784
- {
785
- formId: 'fm:0',
786
- name: 'Application',
787
- fieldCount: 4,
788
- requiredCount: 3,
789
- invalidCount: 0,
790
- fields: [
791
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
792
- { id: 'ff:0.1', kind: 'choice', label: 'Are you legally authorized to work in Germany?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
793
- { id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
794
- { id: 'ff:0.3', kind: 'text', label: 'Why Geometra?' },
795
- ],
796
- },
797
- ];
798
- mockState.currentA11yRoot = node('group', undefined, {
799
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 640 },
800
- children: [
801
- node('textbox', 'Full name', { value: 'Taylor Applicant', path: [0] }),
802
- node('textbox', 'Why Geometra?', { value: longAnswer, path: [1] }),
803
- node('checkbox', 'Share my profile for future roles', {
804
- path: [2],
805
- state: { checked: true },
806
- }),
807
- ],
808
- });
809
- const result = await handler({
810
- valuesById: {
811
- 'ff:0.0': 'Taylor Applicant',
812
- },
813
- valuesByLabel: {
814
- 'Are you legally authorized to work in Germany?': true,
815
- 'Share my profile for future roles': true,
816
- 'Why Geometra?': longAnswer,
817
- },
818
- includeSteps: true,
819
- detail: 'minimal',
820
- });
821
- const text = result.content[0].text;
822
- const payload = JSON.parse(text);
823
- const steps = payload.steps;
824
- expect(text).not.toContain(longAnswer);
825
- expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Are you legally authorized to work in Germany?', 'Yes', { exact: undefined, query: undefined, choiceType: 'group', fieldId: 'ff:0.1' }, undefined);
826
- expect(payload).toMatchObject({
827
- completed: true,
828
- formId: 'fm:0',
829
- requestedValueCount: 4,
830
- fieldCount: 4,
831
- successCount: 4,
832
- errorCount: 0,
833
- });
834
- expect(steps[3]).toMatchObject({
835
- kind: 'text',
836
- fieldLabel: 'Why Geometra?',
837
- valueLength: 220,
838
- readback: { role: 'textbox', valueLength: 220 },
839
- });
840
- });
841
- it('uses batched proxy fill for compact fill_form responses', async () => {
842
- const handler = getToolHandler('geometra_fill_form');
843
- mockState.sendFillFields.mockResolvedValueOnce({
844
- status: 'acknowledged',
845
- timeoutMs: 6000,
846
- result: {
847
- pageUrl: 'https://jobs.example.com/application',
848
- invalidCount: 0,
849
- alertCount: 0,
850
- dialogCount: 0,
851
- busyCount: 0,
852
- },
853
- });
854
- mockState.formSchemas = [
855
- {
856
- formId: 'fm:0',
857
- name: 'Application',
858
- fieldCount: 3,
859
- requiredCount: 2,
860
- invalidCount: 0,
861
- fields: [
862
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
863
- { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, choiceType: 'select' },
864
- { id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
865
- ],
866
- },
867
- ];
868
- mockState.currentA11yRoot = node('group', undefined, {
869
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 640 },
870
- children: [
871
- node('textbox', 'Full name', { value: 'Taylor Applicant', path: [0] }),
872
- node('combobox', 'Preferred location', { value: 'Berlin, Germany', path: [1] }),
873
- node('checkbox', 'Share my profile for future roles', {
874
- path: [2],
875
- state: { checked: true },
876
- }),
877
- ],
878
- });
879
- const result = await handler({
880
- valuesById: {
881
- 'ff:0.0': 'Taylor Applicant',
882
- 'ff:0.1': 'Berlin, Germany',
883
- 'ff:0.2': true,
884
- },
885
- includeSteps: false,
886
- detail: 'minimal',
887
- });
888
- const payload = JSON.parse(result.content[0].text);
889
- expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
890
- { kind: 'text', fieldId: 'ff:0.0', fieldLabel: 'Full name', value: 'Taylor Applicant' },
891
- { kind: 'choice', fieldId: 'ff:0.1', fieldLabel: 'Preferred location', value: 'Berlin, Germany', choiceType: 'select' },
892
- {
893
- kind: 'toggle',
894
- fieldId: 'ff:0.2',
895
- label: 'Share my profile for future roles',
896
- checked: true,
897
- controlType: 'checkbox',
898
- },
899
- ]);
900
- expect(mockState.sendFieldText).not.toHaveBeenCalled();
901
- expect(mockState.sendFieldChoice).not.toHaveBeenCalled();
902
- expect(mockState.sendSetChecked).not.toHaveBeenCalled();
903
- expect(payload).toMatchObject({
904
- completed: true,
905
- execution: 'batched',
906
- finalSource: 'proxy',
907
- formId: 'fm:0',
908
- fieldCount: 3,
909
- successCount: 3,
910
- errorCount: 0,
911
- final: {
912
- invalidCount: 0,
913
- alertCount: 0,
914
- },
915
- });
916
- expect(payload).not.toHaveProperty('steps');
917
- });
918
- it('can auto-connect inside fill_form for known-label one-turn flows', async () => {
919
- const handler = getToolHandler('geometra_fill_form');
920
- mockState.sendFillFields.mockResolvedValueOnce({
921
- status: 'acknowledged',
922
- timeoutMs: 6000,
923
- result: {
924
- pageUrl: 'https://jobs.example.com/application',
925
- invalidCount: 0,
926
- alertCount: 0,
927
- dialogCount: 0,
928
- busyCount: 0,
929
- },
930
- });
931
- mockState.formSchemas = [
932
- {
933
- formId: 'fm:0',
934
- name: 'Application',
935
- fieldCount: 2,
936
- requiredCount: 2,
937
- invalidCount: 0,
938
- fields: [
939
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
940
- { id: 'ff:0.1', kind: 'choice', label: 'Are you legally authorized to work in Germany?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
941
- ],
942
- },
943
- ];
944
- const result = await handler({
945
- pageUrl: 'https://jobs.example.com/application',
946
- headless: true,
947
- valuesByLabel: {
948
- 'Full name': 'Taylor Applicant',
949
- 'Are you legally authorized to work in Germany?': true,
950
- },
951
- includeSteps: false,
952
- detail: 'minimal',
953
- });
954
- const payload = JSON.parse(result.content[0].text);
955
- expect(mockState.connectThroughProxy).toHaveBeenCalledWith({
956
- pageUrl: 'https://jobs.example.com/application',
957
- port: undefined,
958
- headless: true,
959
- width: undefined,
960
- height: undefined,
961
- slowMo: undefined,
962
- awaitInitialFrame: false,
963
- });
964
- expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
965
- { kind: 'auto', fieldLabel: 'Full name', value: 'Taylor Applicant' },
966
- { kind: 'auto', fieldLabel: 'Are you legally authorized to work in Germany?', value: true },
967
- ]);
968
- expect(payload).toMatchObject({
969
- autoConnected: true,
970
- transport: 'proxy',
971
- pageUrl: 'https://jobs.example.com/application',
972
- completed: true,
973
- execution: 'batched-direct',
974
- finalSource: 'proxy',
975
- fieldCount: 2,
976
- successCount: 2,
977
- errorCount: 0,
978
- final: {
979
- invalidCount: 0,
980
- alertCount: 0,
981
- },
982
- });
983
- });
984
- });
985
- describe('submit_form tool', () => {
986
- beforeEach(() => {
987
- vi.clearAllMocks();
988
- resetMockSessionCaches();
989
- mockState.formSchemas = [];
990
- });
991
- it('combines fill + submit-click + post-submit wait in one call', async () => {
992
- const handler = getToolHandler('geometra_submit_form');
993
- mockState.currentA11yRoot = node('group', undefined, {
994
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
995
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
996
- children: [
997
- node('textbox', 'Full name', { value: '', path: [0] }),
998
- node('textbox', 'Email', { value: '', path: [1] }),
999
- node('button', 'Submit application', {
1000
- bounds: { x: 60, y: 480, width: 180, height: 40 },
1001
- path: [2],
1002
- }),
1003
- ],
1004
- });
1005
- mockState.formSchemas = [{
1006
- formId: 'fm:0',
1007
- name: 'Application',
1008
- fieldCount: 2,
1009
- requiredCount: 2,
1010
- invalidCount: 2,
1011
- fields: [
1012
- { id: 'ff:0.0', kind: 'text', label: 'Full name' },
1013
- { id: 'ff:0.1', kind: 'text', label: 'Email' },
1014
- ],
1015
- }];
1016
- mockState.sendFillFields.mockImplementationOnce(async () => ({
1017
- status: 'acknowledged',
1018
- timeoutMs: 6000,
1019
- result: { invalidCount: 0, alertCount: 0, dialogCount: 0, busyCount: 0, pageUrl: 'https://jobs.example.com/application' },
1020
- }));
1021
- mockState.sendClick.mockImplementationOnce(async () => {
1022
- mockState.currentA11yRoot = node('group', undefined, {
1023
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1024
- meta: { pageUrl: 'https://jobs.example.com/confirm', scrollX: 0, scrollY: 0 },
1025
- children: [
1026
- node('dialog', 'Application submitted', {
1027
- bounds: { x: 240, y: 140, width: 420, height: 260 },
1028
- path: [0],
1029
- }),
1030
- ],
1031
- });
1032
- bumpMockUiRevision();
1033
- return { status: 'updated', timeoutMs: 2000 };
1034
- });
1035
- const result = await handler({
1036
- valuesByLabel: { 'Full name': 'Taylor Applicant', Email: 'taylor@example.com' },
1037
- submit: { role: 'button', name: 'Submit application' },
1038
- waitFor: { role: 'dialog', name: 'Application submitted', timeoutMs: 5000 },
1039
- detail: 'minimal',
1040
- });
1041
- const payload = JSON.parse(result.content[0].text);
1042
- expect(payload).toMatchObject({
1043
- completed: true,
1044
- fill: { fieldCount: 2, formId: 'fm:0' },
1045
- submit: { target: { role: 'button', name: 'Submit application' } },
1046
- waitFor: { present: true, matchCount: 1 },
1047
- navigated: true,
1048
- afterUrl: 'https://jobs.example.com/confirm',
1049
- });
1050
- expect(mockState.sendFillFields).toHaveBeenCalledTimes(1);
1051
- expect(mockState.sendClick).toHaveBeenCalledTimes(1);
1052
- });
1053
- it('rejects missing values when skipFill is false', async () => {
1054
- const handler = getToolHandler('geometra_submit_form');
1055
- mockState.currentA11yRoot = node('group', undefined, {
1056
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1057
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1058
- children: [
1059
- node('button', 'Submit', { bounds: { x: 60, y: 480, width: 100, height: 40 }, path: [0] }),
1060
- ],
1061
- });
1062
- const result = await handler({ detail: 'minimal' });
1063
- expect(result.content[0].text).toContain('Provide at least one value');
1064
- });
1065
- it('skipFill: true goes straight to submit + wait', async () => {
1066
- const handler = getToolHandler('geometra_submit_form');
1067
- mockState.currentA11yRoot = node('group', undefined, {
1068
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1069
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1070
- children: [
1071
- node('button', 'Submit', { bounds: { x: 60, y: 480, width: 100, height: 40 }, path: [0] }),
1072
- ],
1073
- });
1074
- mockState.sendClick.mockImplementationOnce(async () => {
1075
- bumpMockUiRevision();
1076
- return { status: 'updated', timeoutMs: 2000 };
1077
- });
1078
- const result = await handler({
1079
- skipFill: true,
1080
- submit: { role: 'button', name: 'Submit' },
1081
- detail: 'minimal',
1082
- });
1083
- const payload = JSON.parse(result.content[0].text);
1084
- expect(payload).toMatchObject({ completed: true });
1085
- expect(payload).not.toHaveProperty('fill');
1086
- expect(mockState.sendFillFields).not.toHaveBeenCalled();
1087
- expect(mockState.sendClick).toHaveBeenCalledTimes(1);
1088
- });
1089
- it('aggregates fill + submit fallback into a top-level fallbacks[] on the submit_form result', async () => {
1090
- const handler = getToolHandler('geometra_submit_form');
1091
- mockState.currentA11yRoot = node('group', undefined, {
1092
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1093
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1094
- children: [
1095
- node('textbox', 'Full name', { value: '', path: [0] }),
1096
- node('button', 'Submit', {
1097
- // Fully offscreen so the fullyVisible resolve misses and the
1098
- // relaxed-visibility fallback picks it up — mirrors the click test above.
1099
- bounds: { x: 60, y: 780, width: 180, height: 60 },
1100
- path: [1],
1101
- }),
1102
- ],
1103
- });
1104
- mockState.formSchemas = [{
1105
- formId: 'fm:0',
1106
- name: 'Application',
1107
- fieldCount: 1,
1108
- requiredCount: 1,
1109
- invalidCount: 1,
1110
- fields: [{ id: 'ff:0.0', kind: 'text', label: 'Full name' }],
1111
- }];
1112
- // Force the fill batched path to throw a recoverable error so submit_form
1113
- // falls through to the sequential fill loop and tags fill fallback.
1114
- mockState.sendFillFields.mockRejectedValue(new Error('Unsupported client message type "fillFields"'));
1115
- mockState.sendClick.mockResolvedValueOnce({ status: 'updated', timeoutMs: 2000 });
1116
- const result = await handler({
1117
- valuesByLabel: { 'Full name': 'Taylor Applicant' },
1118
- submit: { role: 'button', name: 'Submit' },
1119
- submitTimeoutMs: 1000,
1120
- detail: 'minimal',
1121
- });
1122
- const payload = JSON.parse(result.content[0].text);
1123
- const fallbacks = payload.fallbacks;
1124
- expect(Array.isArray(fallbacks)).toBe(true);
1125
- const phases = fallbacks.map(f => f.phase);
1126
- expect(phases).toContain('fill');
1127
- expect(phases).toContain('submit');
1128
- expect(fallbacks.find(f => f.phase === 'fill')).toMatchObject({
1129
- attempted: true, used: true, reason: 'batched-threw',
1130
- });
1131
- expect(fallbacks.find(f => f.phase === 'submit')).toMatchObject({
1132
- attempted: true, used: true, reason: 'relaxed-visibility',
1133
- });
1134
- expect(payload.fill).toMatchObject({ execution: 'sequential' });
1135
- });
1136
- });
1137
- describe('fill transparent fallback', () => {
1138
- beforeEach(() => {
1139
- vi.clearAllMocks();
1140
- resetMockSessionCaches();
1141
- });
1142
- it('geometra_run_actions aggregates step-level fill fallback metadata into top-level fallbacks', async () => {
1143
- const handler = getToolHandler('geometra_run_actions');
1144
- mockState.currentA11yRoot = node('group', undefined, {
1145
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1146
- children: [
1147
- node('textbox', 'Full name', { value: '', path: [0] }),
1148
- ],
1149
- });
1150
- // Force the batched path to reject with a recoverable error so the
1151
- // fill_fields step falls through to the sequential loop and tags the step.
1152
- mockState.sendFillFields.mockRejectedValue(new Error('Unsupported client message type "fillFields"'));
1153
- // includeSteps:false makes the fill_fields step handler prefer the batched
1154
- // fast-path. When that path is unavailable, the step flips to sequential
1155
- // and emits fallback metadata that run_actions lifts into the top-level
1156
- // `fallbacks` array.
1157
- const result = await handler({
1158
- actions: [
1159
- {
1160
- type: 'fill_fields',
1161
- fields: [{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' }],
1162
- },
1163
- ],
1164
- stopOnError: true,
1165
- includeSteps: false,
1166
- detail: 'minimal',
1167
- });
1168
- const payload = JSON.parse(result.content[0].text);
1169
- expect(payload).toMatchObject({
1170
- completed: true,
1171
- stepCount: 1,
1172
- successCount: 1,
1173
- fallbacks: [
1174
- { stepIndex: 0, type: 'fill_fields', attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 },
1175
- ],
1176
- });
1177
- });
1178
- it('geometra_fill_fields surfaces fallback metadata when the batched path is unavailable', async () => {
1179
- const handler = getToolHandler('geometra_fill_fields');
1180
- mockState.currentA11yRoot = node('group', undefined, {
1181
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1182
- children: [
1183
- node('textbox', 'Full name', { value: '', path: [0] }),
1184
- ],
1185
- });
1186
- // Force the batched path to throw a recoverable error so the handler
1187
- // falls through to the sequential loop and tags the fallback.
1188
- mockState.sendFillFields.mockRejectedValueOnce(new Error('Unsupported client message type "fillFields"'));
1189
- const result = await handler({
1190
- fields: [{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' }],
1191
- stopOnError: true,
1192
- failOnInvalid: false,
1193
- includeSteps: false,
1194
- detail: 'minimal',
1195
- });
1196
- const payload = JSON.parse(result.content[0].text);
1197
- expect(payload).toMatchObject({
1198
- completed: true,
1199
- fieldCount: 1,
1200
- successCount: 1,
1201
- errorCount: 0,
1202
- fallback: { attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 },
1203
- });
1204
- });
1205
- it('geometra_fill_form surfaces fallback metadata when batched throws recoverable error', async () => {
1206
- const handler = getToolHandler('geometra_fill_form');
1207
- mockState.currentA11yRoot = node('group', undefined, {
1208
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1209
- children: [
1210
- node('textbox', 'Full name', { value: '', path: [0] }),
1211
- ],
1212
- });
1213
- mockState.formSchemas = [{
1214
- formId: 'fm:0',
1215
- name: 'Application',
1216
- fieldCount: 1,
1217
- requiredCount: 1,
1218
- invalidCount: 1,
1219
- fields: [
1220
- { id: 'ff:0.0', kind: 'text', label: 'Full name' },
1221
- ],
1222
- }];
1223
- // Both the batched-direct path and the schema-backed batched path call
1224
- // sendFillFields. Reject all calls so both hit the recoverable-error
1225
- // branch and the handler lands in the sequential loop.
1226
- mockState.sendFillFields.mockRejectedValue(new Error('Unsupported client message type "fillFields"'));
1227
- const result = await handler({
1228
- valuesByLabel: { 'Full name': 'Taylor Applicant' },
1229
- includeSteps: false,
1230
- detail: 'minimal',
1231
- });
1232
- const payload = JSON.parse(result.content[0].text);
1233
- expect(payload).toMatchObject({
1234
- execution: 'sequential',
1235
- fallback: { attempted: true, used: true, reason: 'batched-threw', attempts: 2 },
1236
- });
1237
- });
1238
- });
1239
- describe('click transparent fallback', () => {
1240
- beforeEach(() => {
1241
- vi.clearAllMocks();
1242
- resetMockSessionCaches();
1243
- });
1244
- it('surfaces fallback.attempted:false when click fallback attempted and failed', async () => {
1245
- const handler = getToolHandler('geometra_click');
1246
- mockState.currentA11yRoot = node('group', undefined, {
1247
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1248
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1249
- children: [],
1250
- });
1251
- const result = await handler({
1252
- role: 'button',
1253
- name: 'Does not exist',
1254
- fullyVisible: true,
1255
- maxRevealSteps: 1,
1256
- revealTimeoutMs: 50,
1257
- detail: 'terse',
1258
- });
1259
- // Fallback was attempted (both revision-retry — if mockWaitForUiCondition
1260
- // returns true — and relaxed-visibility) but neither phase recovered the
1261
- // missing target, so the handler returns a structured error carrying the
1262
- // attempted-but-failed telemetry.
1263
- const errorText = result.content[0].text;
1264
- expect(errorText).toContain('"fallback"');
1265
- const parsed = JSON.parse(errorText);
1266
- expect(parsed.fallback).toMatchObject({ attempted: true, used: false });
1267
- expect(parsed.fallback.reasonsTried.length).toBeGreaterThan(0);
1268
- });
1269
- it('surfaces fallback.used when relaxed-visibility lets an offscreen submit resolve', async () => {
1270
- const handler = getToolHandler('geometra_click');
1271
- // First tree: target exists but is offscreen below the viewport, so a
1272
- // fullyVisible requirement cannot be satisfied before the reveal budget runs out.
1273
- // The relaxed-visibility fallback drops the fullyVisible requirement and tries
1274
- // once more with a larger reveal budget.
1275
- const offscreenTree = node('group', undefined, {
1276
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1277
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1278
- children: [
1279
- node('button', 'Submit', {
1280
- // Starts fully offscreen-below and wheel stubs don't move it in tests,
1281
- // so the fullyVisible attempt will fail. Relaxed-visibility sees it
1282
- // intersect enough to count as revealed.
1283
- bounds: { x: 60, y: 780, width: 180, height: 60 },
1284
- path: [0],
1285
- }),
1286
- ],
1287
- });
1288
- mockState.currentA11yRoot = offscreenTree;
1289
- mockState.sendClick.mockResolvedValueOnce({ status: 'updated', timeoutMs: 2000 });
1290
- const result = await handler({
1291
- role: 'button',
1292
- name: 'Submit',
1293
- fullyVisible: true,
1294
- maxRevealSteps: 1,
1295
- revealTimeoutMs: 100,
1296
- detail: 'terse',
1297
- });
1298
- const payload = JSON.parse(result.content[0].text);
1299
- expect(payload).toMatchObject({
1300
- target: { role: 'button', name: 'Submit' },
1301
- fallback: { attempted: true, used: true, reason: 'relaxed-visibility' },
1302
- });
1303
- });
1304
- });
1305
- describe('query and reveal tools', () => {
1306
- beforeEach(() => {
1307
- vi.clearAllMocks();
1308
- resetMockSessionCaches();
1309
- });
1310
- it('lets query disambiguate repeated controls by context text', async () => {
1311
- const handler = getToolHandler('geometra_query');
1312
- mockState.currentA11yRoot = node('group', undefined, {
1313
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 900 },
1314
- children: [
1315
- node('form', 'Application', {
1316
- path: [0],
1317
- children: [
1318
- node('group', undefined, {
1319
- path: [0, 0],
1320
- children: [
1321
- node('text', 'Are you legally authorized to work here?', { path: [0, 0, 0] }),
1322
- node('button', 'Yes', { path: [0, 0, 1] }),
1323
- node('button', 'No', { path: [0, 0, 2] }),
1324
- ],
1325
- }),
1326
- node('group', undefined, {
1327
- path: [0, 1],
1328
- children: [
1329
- node('text', 'Will you require sponsorship?', { path: [0, 1, 0] }),
1330
- node('button', 'Yes', { path: [0, 1, 1] }),
1331
- node('button', 'No', { path: [0, 1, 2] }),
1332
- ],
1333
- }),
1334
- ],
1335
- }),
1336
- ],
1337
- });
1338
- mockState.nodeContexts.set('0.0.1', {
1339
- prompt: 'Are you legally authorized to work here?',
1340
- section: 'Application',
1341
- });
1342
- mockState.nodeContexts.set('0.1.1', {
1343
- prompt: 'Will you require sponsorship?',
1344
- section: 'Application',
1345
- });
1346
- const result = await handler({
1347
- role: 'button',
1348
- name: 'Yes',
1349
- contextText: 'sponsorship',
1350
- });
1351
- const payload = JSON.parse(result.content[0].text);
1352
- expect(payload).toHaveLength(1);
1353
- expect(payload[0]).toMatchObject({
1354
- role: 'button',
1355
- name: 'Yes',
1356
- context: {
1357
- prompt: 'Will you require sponsorship?',
1358
- section: 'Application',
1359
- },
1360
- });
1361
- });
1362
- it('falls back to sequential fill when a batched fill ends without a clean ack and invalid fields remain', async () => {
1363
- const handler = getToolHandler('geometra_fill_form');
1364
- mockState.sendFillFields.mockResolvedValueOnce({
1365
- status: 'updated',
1366
- timeoutMs: 6000,
1367
- result: undefined,
1368
- });
1369
- mockState.formSchemas = [
1370
- {
1371
- formId: 'fm:0',
1372
- name: 'Application',
1373
- fieldCount: 2,
1374
- requiredCount: 2,
1375
- invalidCount: 2,
1376
- fields: [
1377
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true, invalid: true },
1378
- { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, invalid: true, choiceType: 'select' },
1379
- ],
1380
- },
1381
- ];
1382
- mockState.currentA11yRoot = node('group', undefined, {
1383
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 640 },
1384
- children: [
1385
- node('textbox', 'Full name', {
1386
- path: [0],
1387
- state: { required: true, invalid: true },
1388
- }),
1389
- node('combobox', 'Preferred location', {
1390
- path: [1],
1391
- value: 'Select',
1392
- state: { required: true, invalid: true },
1393
- }),
1394
- ],
1395
- });
1396
- const result = await handler({
1397
- valuesById: {
1398
- 'ff:0.0': 'Taylor Applicant',
1399
- 'ff:0.1': 'Berlin, Germany',
1400
- },
1401
- includeSteps: false,
1402
- detail: 'minimal',
1403
- failOnInvalid: false,
1404
- });
1405
- const payload = JSON.parse(result.content[0].text);
1406
- expect(mockState.sendFillFields).toHaveBeenCalledTimes(1);
1407
- expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Full name', 'Taylor Applicant', { exact: undefined, fieldId: 'ff:0.0' }, undefined);
1408
- expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Preferred location', 'Berlin, Germany', { exact: undefined, query: undefined, choiceType: 'select', fieldId: 'ff:0.1' }, undefined);
1409
- expect(payload).toMatchObject({
1410
- completed: true,
1411
- execution: 'sequential',
1412
- formId: 'fm:0',
1413
- requestedValueCount: 2,
1414
- fieldCount: 2,
1415
- successCount: 2,
1416
- errorCount: 0,
1417
- });
1418
- });
1419
- it('reveals an offscreen target with semantic scrolling instead of requiring manual wheels', async () => {
1420
- const handler = getToolHandler('geometra_scroll_to');
1421
- mockState.currentA11yRoot = node('group', undefined, {
1422
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1423
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1424
- children: [
1425
- node('form', 'Application', {
1426
- bounds: { x: 20, y: -200, width: 760, height: 1900 },
1427
- path: [0],
1428
- children: [
1429
- node('button', 'Submit application', {
1430
- bounds: { x: 60, y: 1540, width: 180, height: 40 },
1431
- path: [0, 0],
1432
- }),
1433
- ],
1434
- }),
1435
- ],
1436
- });
1437
- mockState.sendWheel.mockImplementationOnce(async () => {
1438
- mockState.currentA11yRoot = node('group', undefined, {
1439
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1440
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 1220 },
1441
- children: [
1442
- node('form', 'Application', {
1443
- bounds: { x: 20, y: -1420, width: 760, height: 1900 },
1444
- path: [0],
1445
- children: [
1446
- node('button', 'Submit application', {
1447
- bounds: { x: 60, y: 320, width: 180, height: 40 },
1448
- path: [0, 0],
1449
- }),
1450
- ],
1451
- }),
1452
- ],
1453
- });
1454
- bumpMockUiRevision();
1455
- return { status: 'updated', timeoutMs: 2500 };
1456
- });
1457
- const result = await handler({
1458
- role: 'button',
1459
- name: 'Submit application',
1460
- maxSteps: 3,
1461
- fullyVisible: true,
1462
- timeoutMs: 2500,
1463
- });
1464
- const payload = JSON.parse(result.content[0].text);
1465
- expect(mockState.sendWheel).toHaveBeenCalledWith(mockState.session, expect.any(Number), expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }), 2500);
1466
- expect(payload).toMatchObject({
1467
- revealed: true,
1468
- attempts: 1,
1469
- target: {
1470
- role: 'button',
1471
- name: 'Submit application',
1472
- visibility: { fullyVisible: true },
1473
- },
1474
- });
1475
- });
1476
- it('auto-scales reveal steps for tall forms when maxSteps is omitted', async () => {
1477
- const handler = getToolHandler('geometra_scroll_to');
1478
- let scrollY = 0;
1479
- const setTree = () => {
1480
- mockState.currentA11yRoot = node('group', undefined, {
1481
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1482
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY },
1483
- children: [
1484
- node('form', 'Application', {
1485
- bounds: { x: 20, y: -200 - scrollY, width: 760, height: 6200 },
1486
- path: [0],
1487
- children: [
1488
- node('button', 'Submit application', {
1489
- bounds: { x: 60, y: 5200 - scrollY, width: 180, height: 40 },
1490
- path: [0, 0],
1491
- }),
1492
- ],
1493
- }),
1494
- ],
1495
- });
1496
- };
1497
- setTree();
1498
- mockState.sendWheel.mockImplementation(async () => {
1499
- scrollY += 650;
1500
- setTree();
1501
- bumpMockUiRevision();
1502
- return { status: 'updated', timeoutMs: 2500 };
1503
- });
1504
- const result = await handler({
1505
- role: 'button',
1506
- name: 'Submit application',
1507
- fullyVisible: true,
1508
- timeoutMs: 2500,
1509
- });
1510
- const payload = JSON.parse(result.content[0].text);
1511
- expect(mockState.sendWheel).toHaveBeenCalledTimes(7);
1512
- expect(payload).toMatchObject({
1513
- revealed: true,
1514
- attempts: 7,
1515
- target: {
1516
- role: 'button',
1517
- name: 'Submit application',
1518
- visibility: { fullyVisible: true },
1519
- },
1520
- });
1521
- });
1522
- it('clicks an offscreen semantic target by revealing it first', async () => {
1523
- const handler = getToolHandler('geometra_click');
1524
- mockState.currentA11yRoot = node('group', undefined, {
1525
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1526
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1527
- children: [
1528
- node('form', 'Application', {
1529
- bounds: { x: 20, y: -200, width: 760, height: 1900 },
1530
- path: [0],
1531
- children: [
1532
- node('button', 'Submit application', {
1533
- bounds: { x: 60, y: 1540, width: 180, height: 40 },
1534
- path: [0, 0],
1535
- }),
1536
- ],
1537
- }),
1538
- ],
1539
- });
1540
- mockState.sendWheel.mockImplementationOnce(async () => {
1541
- mockState.currentA11yRoot = node('group', undefined, {
1542
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1543
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 1220 },
1544
- children: [
1545
- node('form', 'Application', {
1546
- bounds: { x: 20, y: -1420, width: 760, height: 1900 },
1547
- path: [0],
1548
- children: [
1549
- node('button', 'Submit application', {
1550
- bounds: { x: 60, y: 320, width: 180, height: 40 },
1551
- path: [0, 0],
1552
- }),
1553
- ],
1554
- }),
1555
- ],
1556
- });
1557
- bumpMockUiRevision();
1558
- return { status: 'updated', timeoutMs: 2500 };
1559
- });
1560
- const result = await handler({
1561
- id: 'n:0.0',
1562
- maxRevealSteps: 3,
1563
- revealTimeoutMs: 2500,
1564
- detail: 'minimal',
1565
- });
1566
- expect(mockState.sendWheel).toHaveBeenCalledTimes(1);
1567
- expect(mockState.sendClick).toHaveBeenCalledWith(mockState.session, 150, 340, undefined);
1568
- expect(result.content[0].text).toContain('Clicked button "Submit application" (n:0.0) at (150, 340) after 1 reveal step.');
1569
- });
1570
- it('can wait for a semantic post-click condition in the same click call', async () => {
1571
- const handler = getToolHandler('geometra_click');
1572
- mockState.currentA11yRoot = node('group', undefined, {
1573
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1574
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1575
- children: [
1576
- node('button', 'Submit application', {
1577
- bounds: { x: 60, y: 320, width: 180, height: 40 },
1578
- path: [0],
1579
- }),
1580
- ],
1581
- });
1582
- mockState.sendClick.mockImplementationOnce(async () => {
1583
- mockState.currentA11yRoot = node('group', undefined, {
1584
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1585
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1586
- children: [
1587
- node('button', 'Submit application', {
1588
- bounds: { x: 60, y: 320, width: 180, height: 40 },
1589
- path: [0],
1590
- }),
1591
- node('dialog', 'Application submitted', {
1592
- bounds: { x: 240, y: 140, width: 420, height: 260 },
1593
- path: [1],
1594
- }),
1595
- ],
1596
- });
1597
- bumpMockUiRevision();
1598
- return { status: 'updated', timeoutMs: 2000 };
1599
- });
1600
- const result = await handler({
1601
- role: 'button',
1602
- name: 'Submit application',
1603
- waitFor: {
1604
- role: 'dialog',
1605
- name: 'Application submitted',
1606
- timeoutMs: 5000,
1607
- },
1608
- detail: 'minimal',
1609
- });
1610
- expect(mockState.waitForUiCondition).toHaveBeenCalledWith(mockState.session, expect.any(Function), 5000);
1611
- expect(result.content[0].text).toContain('Post-click condition satisfied after');
1612
- expect(result.content[0].text).toContain('1 matching node(s).');
1613
- });
1614
- it('waits for the initial tree before a semantic click after deferred connect', async () => {
1615
- const handler = getToolHandler('geometra_click');
1616
- mockState.session.tree = null;
1617
- mockState.session.layout = null;
1618
- mockState.currentA11yRoot = node('group', undefined, {
1619
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1620
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1621
- children: [
1622
- node('button', 'Open incident', {
1623
- bounds: { x: 40, y: 120, width: 140, height: 40 },
1624
- path: [0],
1625
- }),
1626
- ],
1627
- });
1628
- mockState.waitForUiCondition.mockImplementationOnce(async (_session, check) => {
1629
- mockState.session.tree = { kind: 'box' };
1630
- mockState.session.layout = {
1631
- x: 0,
1632
- y: 0,
1633
- width: 1280,
1634
- height: 800,
1635
- children: [],
1636
- };
1637
- bumpMockUiRevision();
1638
- return check();
1639
- });
1640
- const result = await handler({
1641
- role: 'button',
1642
- name: 'Open incident',
1643
- detail: 'minimal',
1644
- });
1645
- expect(mockState.waitForUiCondition).toHaveBeenCalledWith(mockState.session, expect.any(Function), 4000);
1646
- expect(mockState.sendClick).toHaveBeenCalledWith(mockState.session, 110, 140, undefined);
1647
- expect(result.content[0].text).toContain('Clicked button "Open incident" (n:0) at (110, 140).');
1648
- });
1649
- it('lets run_actions click a semantic target without manual coordinates', async () => {
1650
- const handler = getToolHandler('geometra_run_actions');
1651
- mockState.currentA11yRoot = node('group', undefined, {
1652
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1653
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1654
- children: [
1655
- node('form', 'Application', {
1656
- bounds: { x: 20, y: -200, width: 760, height: 1900 },
1657
- path: [0],
1658
- children: [
1659
- node('button', 'Submit application', {
1660
- bounds: { x: 60, y: 1540, width: 180, height: 40 },
1661
- path: [0, 0],
1662
- }),
1663
- ],
1664
- }),
1665
- ],
1666
- });
1667
- mockState.sendWheel.mockImplementationOnce(async () => {
1668
- mockState.currentA11yRoot = node('group', undefined, {
1669
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1670
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 1220 },
1671
- children: [
1672
- node('form', 'Application', {
1673
- bounds: { x: 20, y: -1420, width: 760, height: 1900 },
1674
- path: [0],
1675
- children: [
1676
- node('button', 'Submit application', {
1677
- bounds: { x: 60, y: 320, width: 180, height: 40 },
1678
- path: [0, 0],
1679
- }),
1680
- ],
1681
- }),
1682
- ],
1683
- });
1684
- bumpMockUiRevision();
1685
- return { status: 'updated', timeoutMs: 2500 };
1686
- });
1687
- const result = await handler({
1688
- actions: [
1689
- {
1690
- type: 'click',
1691
- role: 'button',
1692
- name: 'Submit application',
1693
- maxRevealSteps: 3,
1694
- revealTimeoutMs: 2500,
1695
- },
1696
- ],
1697
- stopOnError: true,
1698
- includeSteps: true,
1699
- detail: 'minimal',
1700
- });
1701
- const payload = JSON.parse(result.content[0].text);
1702
- const steps = payload.steps;
1703
- expect(mockState.sendWheel).toHaveBeenCalledTimes(1);
1704
- expect(mockState.sendClick).toHaveBeenCalledWith(mockState.session, 150, 340, undefined);
1705
- expect(steps[0]).toMatchObject({
1706
- index: 0,
1707
- type: 'click',
1708
- ok: true,
1709
- elapsedMs: expect.any(Number),
1710
- at: { x: 150, y: 340 },
1711
- revealSteps: 1,
1712
- target: { id: 'n:0.0', role: 'button', name: 'Submit application' },
1713
- wait: 'updated',
1714
- });
1715
- });
1716
- it('lets run_actions click and wait for a semantic post-condition in one step', async () => {
1717
- const handler = getToolHandler('geometra_run_actions');
1718
- mockState.currentA11yRoot = node('group', undefined, {
1719
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1720
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1721
- children: [
1722
- node('button', 'Submit application', {
1723
- bounds: { x: 60, y: 320, width: 180, height: 40 },
1724
- path: [0],
1725
- }),
1726
- ],
1727
- });
1728
- mockState.sendClick.mockImplementationOnce(async () => {
1729
- mockState.currentA11yRoot = node('group', undefined, {
1730
- bounds: { x: 0, y: 0, width: 1280, height: 800 },
1731
- meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1732
- children: [
1733
- node('button', 'Submit application', {
1734
- bounds: { x: 60, y: 320, width: 180, height: 40 },
1735
- path: [0],
1736
- }),
1737
- node('dialog', 'Application submitted', {
1738
- bounds: { x: 240, y: 140, width: 420, height: 260 },
1739
- path: [1],
1740
- }),
1741
- ],
1742
- });
1743
- bumpMockUiRevision();
1744
- return { status: 'updated', timeoutMs: 2000 };
1745
- });
1746
- const result = await handler({
1747
- actions: [
1748
- {
1749
- type: 'click',
1750
- role: 'button',
1751
- name: 'Submit application',
1752
- waitFor: {
1753
- role: 'dialog',
1754
- name: 'Application submitted',
1755
- timeoutMs: 5000,
1756
- },
1757
- },
1758
- ],
1759
- stopOnError: true,
1760
- includeSteps: true,
1761
- detail: 'minimal',
1762
- });
1763
- const payload = JSON.parse(result.content[0].text);
1764
- const steps = payload.steps;
1765
- expect(steps[0]).toMatchObject({
1766
- index: 0,
1767
- type: 'click',
1768
- ok: true,
1769
- elapsedMs: expect.any(Number),
1770
- postWait: {
1771
- present: true,
1772
- matchCount: 1,
1773
- filter: { role: 'dialog', name: 'Application submitted' },
1774
- },
1775
- });
1776
- });
1777
- });