@geometra/mcp 1.59.1 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,891 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- // ── Node builder ─────────────────────────────────────────────────────
3
- function node(role, name, options) {
4
- return {
5
- role,
6
- ...(name ? { name } : {}),
7
- ...(options?.value ? { value: options.value } : {}),
8
- ...(options?.state ? { state: options.state } : {}),
9
- ...(options?.validation ? { validation: options.validation } : {}),
10
- ...(options?.meta ? { meta: options.meta } : {}),
11
- bounds: options?.bounds ?? { x: 0, y: 0, width: 120, height: 40 },
12
- path: options?.path ?? [],
13
- children: options?.children ?? [],
14
- focusable: role !== 'group',
15
- };
16
- }
17
- // ── Mock session state ───────────────────────────────────────────────
18
- const mockState = vi.hoisted(() => ({
19
- currentA11yRoot: node('group', undefined, {
20
- meta: { pageUrl: 'https://boards.greenhouse.io/apply', scrollX: 0, scrollY: 0 },
21
- }),
22
- nodeContexts: new Map(),
23
- session: {
24
- tree: { kind: 'box' },
25
- layout: { x: 0, y: 0, width: 1280, height: 800, children: [] },
26
- url: 'ws://127.0.0.1:3200',
27
- updateRevision: 1,
28
- cachedA11y: undefined,
29
- cachedA11yRevision: undefined,
30
- cachedFormSchemas: undefined,
31
- workflowState: undefined,
32
- },
33
- formSchemas: [],
34
- connect: vi.fn(),
35
- connectThroughProxy: vi.fn(),
36
- prewarmProxy: vi.fn(),
37
- sendClick: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
38
- sendType: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
39
- sendKey: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
40
- sendFileUpload: vi.fn(async () => ({ status: 'updated', timeoutMs: 8000 })),
41
- sendFieldText: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
42
- sendFieldChoice: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
43
- sendFillFields: vi.fn(async () => ({
44
- status: 'updated',
45
- timeoutMs: 6000,
46
- result: undefined,
47
- })),
48
- sendListboxPick: vi.fn(async () => ({ status: 'updated', timeoutMs: 4500 })),
49
- sendSelectOption: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
50
- sendSetChecked: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
51
- sendWheel: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
52
- waitForUiCondition: vi.fn(async (_session, _check, _timeoutMs) => true),
53
- }));
54
- function resetMockSessionCaches() {
55
- mockState.session.updateRevision = 1;
56
- mockState.session.cachedA11y = undefined;
57
- mockState.session.cachedA11yRevision = undefined;
58
- mockState.session.cachedFormSchemas = undefined;
59
- mockState.session.workflowState = undefined;
60
- mockState.nodeContexts.clear();
61
- }
62
- function bumpMockUiRevision() {
63
- mockState.session.updateRevision += 1;
64
- mockState.session.cachedA11y = undefined;
65
- mockState.session.cachedA11yRevision = undefined;
66
- mockState.session.cachedFormSchemas = undefined;
67
- }
68
- vi.mock('../session.js', () => ({
69
- connect: mockState.connect,
70
- connectThroughProxy: mockState.connectThroughProxy,
71
- prewarmProxy: mockState.prewarmProxy,
72
- disconnect: vi.fn(),
73
- pruneDisconnectedSessions: vi.fn(() => []),
74
- getSession: vi.fn(() => mockState.session),
75
- resolveSession: vi.fn((id) => ({ kind: 'ok', session: mockState.session, ...(id ? { id } : {}) })),
76
- listSessions: vi.fn(() => [{ id: 's1', url: 'https://jobs.example.com/application' }]),
77
- getDefaultSessionId: vi.fn(() => 's1'),
78
- sendClick: mockState.sendClick,
79
- sendType: mockState.sendType,
80
- sendKey: mockState.sendKey,
81
- sendFileUpload: mockState.sendFileUpload,
82
- sendFieldText: mockState.sendFieldText,
83
- sendFieldChoice: mockState.sendFieldChoice,
84
- sendFillFields: mockState.sendFillFields,
85
- sendFillOtp: vi.fn(async () => ({ status: 'updated', timeoutMs: 5000, result: { cellCount: 6, filledCount: 6 } })),
86
- sendListboxPick: mockState.sendListboxPick,
87
- sendSelectOption: mockState.sendSelectOption,
88
- sendSetChecked: mockState.sendSetChecked,
89
- sendWheel: mockState.sendWheel,
90
- buildA11yTree: vi.fn(() => mockState.currentA11yRoot),
91
- buildCompactUiIndex: vi.fn(() => ({ nodes: [], context: {} })),
92
- buildPageModel: vi.fn(() => ({
93
- viewport: { width: 1280, height: 800 },
94
- archetypes: ['form'],
95
- summary: { landmarkCount: 0, formCount: 1, dialogCount: 0, listCount: 0, focusableCount: 6 },
96
- primaryActions: [],
97
- landmarks: [],
98
- forms: [],
99
- dialogs: [],
100
- lists: [],
101
- })),
102
- buildFormSchemas: vi.fn(() => mockState.formSchemas),
103
- buildFormRequiredSnapshot: vi.fn(() => []),
104
- expandPageSection: vi.fn(() => null),
105
- buildUiDelta: vi.fn(() => ({})),
106
- hasUiDelta: vi.fn(() => false),
107
- nodeIdForPath: vi.fn((path) => `n:${path.length > 0 ? path.join('.') : 'root'}`),
108
- summarizeCompactIndex: vi.fn(() => ''),
109
- summarizePageModel: vi.fn(() => ''),
110
- summarizeUiDelta: vi.fn(() => ''),
111
- nodeContextForNode: vi.fn((_, nd) => mockState.nodeContexts.get((nd.path ?? []).join('.'))),
112
- waitForUiCondition: mockState.waitForUiCondition,
113
- }));
114
- const { createServer } = await import('../server.js');
115
- function getToolHandler(name) {
116
- const server = createServer();
117
- return server._registeredTools[name].handler;
118
- }
119
- // ── Shared beforeEach ────────────────────────────────────────────────
120
- describe('ATS integration patterns', () => {
121
- beforeEach(() => {
122
- vi.clearAllMocks();
123
- resetMockSessionCaches();
124
- mockState.connect.mockResolvedValue(mockState.session);
125
- mockState.connectThroughProxy.mockResolvedValue(mockState.session);
126
- mockState.prewarmProxy.mockResolvedValue({
127
- prepared: true,
128
- reused: false,
129
- transport: 'embedded',
130
- pageUrl: 'https://boards.greenhouse.io/apply',
131
- wsUrl: 'ws://127.0.0.1:3200',
132
- headless: true,
133
- width: 1280,
134
- height: 720,
135
- });
136
- mockState.formSchemas = [];
137
- mockState.currentA11yRoot = node('group', undefined, {
138
- meta: { pageUrl: 'https://boards.greenhouse.io/apply', scrollX: 0, scrollY: 0 },
139
- });
140
- });
141
- // ── 1. Greenhouse-style ──────────────────────────────────────────
142
- describe('Greenhouse-style simple form', () => {
143
- beforeEach(() => {
144
- mockState.formSchemas = [{
145
- formId: 'fm:0',
146
- name: 'Application',
147
- fieldCount: 5,
148
- requiredCount: 3,
149
- invalidCount: 0,
150
- fields: [
151
- { id: 'ff:0.0', kind: 'text', label: 'First name', required: true },
152
- { id: 'ff:0.1', kind: 'text', label: 'Last name', required: true },
153
- { id: 'ff:0.2', kind: 'text', label: 'Email', required: true },
154
- { id: 'ff:0.3', kind: 'choice', label: 'Location', choiceType: 'combobox', optionCount: 15 },
155
- { id: 'ff:0.4', kind: 'choice', label: 'Are you legally authorized to work in the United States?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
156
- ],
157
- }];
158
- mockState.currentA11yRoot = node('group', undefined, {
159
- meta: { pageUrl: 'https://boards.greenhouse.io/apply', scrollX: 0, scrollY: 0 },
160
- children: [
161
- node('textbox', 'First name', { value: 'Taylor', path: [0], state: { required: true } }),
162
- node('textbox', 'Last name', { value: 'Smith', path: [1], state: { required: true } }),
163
- node('textbox', 'Email', { value: 'taylor@example.com', path: [2], state: { required: true } }),
164
- node('combobox', 'Location', { value: 'San Francisco, CA', path: [3] }),
165
- node('radio', 'Yes', { path: [4, 0], state: { checked: true } }),
166
- node('radio', 'No', { path: [4, 1], state: { checked: false } }),
167
- ],
168
- });
169
- });
170
- it('discovers form_schema and fills with valuesById', async () => {
171
- const schemaHandler = getToolHandler('geometra_form_schema');
172
- const schemaResult = await schemaHandler({});
173
- const schemaPayload = JSON.parse(schemaResult.content[0].text);
174
- expect(schemaPayload).toHaveProperty('forms');
175
- const fillHandler = getToolHandler('geometra_fill_form');
176
- const fillResult = await fillHandler({
177
- formId: 'fm:0',
178
- valuesById: {
179
- 'ff:0.0': 'Taylor',
180
- 'ff:0.1': 'Smith',
181
- 'ff:0.2': 'taylor@example.com',
182
- 'ff:0.3': 'San Francisco, CA',
183
- 'ff:0.4': 'Yes',
184
- },
185
- stopOnError: true,
186
- failOnInvalid: false,
187
- includeSteps: true,
188
- detail: 'minimal',
189
- });
190
- const payload = JSON.parse(fillResult.content[0].text);
191
- expect(payload).toMatchObject({
192
- completed: true,
193
- fieldCount: 5,
194
- successCount: 5,
195
- errorCount: 0,
196
- });
197
- expect(payload.minConfidence).toBe(1.0);
198
- });
199
- it('verifies fills by reading back field values after completion', async () => {
200
- const handler = getToolHandler('geometra_fill_form');
201
- const result = await handler({
202
- formId: 'fm:0',
203
- valuesById: {
204
- 'ff:0.0': 'Taylor',
205
- 'ff:0.1': 'Smith',
206
- 'ff:0.2': 'taylor@example.com',
207
- },
208
- stopOnError: true,
209
- failOnInvalid: false,
210
- includeSteps: true,
211
- verifyFills: true,
212
- detail: 'minimal',
213
- });
214
- const payload = JSON.parse(result.content[0].text);
215
- expect(payload).toMatchObject({ completed: true, successCount: 3, errorCount: 0 });
216
- });
217
- });
218
- // ── 2. Workday-style ─────────────────────────────────────────────
219
- describe('Workday-style multi-section form', () => {
220
- beforeEach(() => {
221
- mockState.formSchemas = [{
222
- formId: 'fm:0',
223
- name: 'Job Application',
224
- fieldCount: 8,
225
- requiredCount: 5,
226
- invalidCount: 0,
227
- fields: [
228
- { id: 'ff:0.0', kind: 'text', label: 'Legal First Name', required: true },
229
- { id: 'ff:0.1', kind: 'text', label: 'Legal Last Name', required: true },
230
- { id: 'ff:0.2', kind: 'text', label: 'Email Address', required: true },
231
- { id: 'ff:0.3', kind: 'text', label: 'Phone Number', required: true, format: { placeholder: '(555) 123-4567' } },
232
- { id: 'ff:0.4', kind: 'text', label: 'Job Title', required: false },
233
- { id: 'ff:0.5', kind: 'text', label: 'Company', required: false },
234
- { id: 'ff:0.6', kind: 'text', label: 'School or University', required: true },
235
- { id: 'ff:0.7', kind: 'choice', label: 'Degree', choiceType: 'select', optionCount: 8 },
236
- ],
237
- sections: [
238
- { name: 'Personal Information', fieldIds: ['ff:0.0', 'ff:0.1', 'ff:0.2', 'ff:0.3'] },
239
- { name: 'Work Experience', fieldIds: ['ff:0.4', 'ff:0.5'] },
240
- { name: 'Education', fieldIds: ['ff:0.6', 'ff:0.7'] },
241
- ],
242
- }];
243
- mockState.currentA11yRoot = node('group', undefined, {
244
- meta: { pageUrl: 'https://myworkday.com/apply', scrollX: 0, scrollY: 0 },
245
- children: [
246
- node('group', 'Personal Information', {
247
- path: [0],
248
- children: [
249
- node('textbox', 'Legal First Name', { value: 'Taylor', path: [0, 0], state: { required: true } }),
250
- node('textbox', 'Legal Last Name', { value: 'Smith', path: [0, 1], state: { required: true } }),
251
- node('textbox', 'Email Address', { value: 'taylor@example.com', path: [0, 2], state: { required: true } }),
252
- node('textbox', 'Phone Number', {
253
- value: '(555) 987-6543',
254
- path: [0, 3],
255
- state: { required: true },
256
- meta: { placeholder: '(555) 123-4567' },
257
- }),
258
- ],
259
- }),
260
- node('group', 'Work Experience', {
261
- path: [1],
262
- children: [
263
- node('textbox', 'Job Title', { value: 'Software Engineer', path: [1, 0] }),
264
- node('textbox', 'Company', { value: 'Acme Corp', path: [1, 1] }),
265
- ],
266
- }),
267
- node('group', 'Education', {
268
- path: [2],
269
- children: [
270
- node('textbox', 'School or University', { value: 'MIT', path: [2, 0], state: { required: true } }),
271
- node('combobox', 'Degree', { value: "Bachelor's", path: [2, 1] }),
272
- ],
273
- }),
274
- ],
275
- });
276
- });
277
- it('fills required fields only using onlyRequiredFields discovery', async () => {
278
- const schemaHandler = getToolHandler('geometra_form_schema');
279
- const schemaResult = await schemaHandler({ onlyRequiredFields: true });
280
- const schemaPayload = JSON.parse(schemaResult.content[0].text);
281
- expect(schemaPayload).toHaveProperty('forms');
282
- const fillHandler = getToolHandler('geometra_fill_form');
283
- const result = await fillHandler({
284
- formId: 'fm:0',
285
- valuesById: {
286
- 'ff:0.0': 'Taylor',
287
- 'ff:0.1': 'Smith',
288
- 'ff:0.2': 'taylor@example.com',
289
- 'ff:0.3': '(555) 987-6543',
290
- 'ff:0.6': 'MIT',
291
- },
292
- stopOnError: true,
293
- failOnInvalid: false,
294
- includeSteps: true,
295
- detail: 'minimal',
296
- });
297
- const payload = JSON.parse(result.content[0].text);
298
- expect(payload).toMatchObject({
299
- completed: true,
300
- fieldCount: 5,
301
- successCount: 5,
302
- errorCount: 0,
303
- minConfidence: 1.0,
304
- });
305
- });
306
- it('skips pre-filled fields when skipPreFilled is true', async () => {
307
- // Set up schema where some fields already have matching values
308
- mockState.formSchemas = [{
309
- formId: 'fm:0',
310
- name: 'Job Application',
311
- fieldCount: 3,
312
- requiredCount: 3,
313
- invalidCount: 0,
314
- fields: [
315
- { id: 'ff:0.0', kind: 'text', label: 'Legal First Name', required: true, value: 'Taylor' },
316
- { id: 'ff:0.1', kind: 'text', label: 'Legal Last Name', required: true, value: 'Smith' },
317
- { id: 'ff:0.2', kind: 'text', label: 'Email Address', required: true },
318
- ],
319
- }];
320
- const handler = getToolHandler('geometra_fill_form');
321
- const result = await handler({
322
- formId: 'fm:0',
323
- valuesById: {
324
- 'ff:0.0': 'Taylor',
325
- 'ff:0.1': 'Smith',
326
- 'ff:0.2': 'taylor@example.com',
327
- },
328
- stopOnError: true,
329
- failOnInvalid: false,
330
- includeSteps: true,
331
- skipPreFilled: true,
332
- detail: 'minimal',
333
- });
334
- const payload = JSON.parse(result.content[0].text);
335
- expect(payload).toMatchObject({ completed: true });
336
- // First name and last name were already filled with matching values, so they should be skipped
337
- expect(payload).toHaveProperty('skippedPreFilled', 2);
338
- });
339
- });
340
- // ── 3. Lever-style ───────────────────────────────────────────────
341
- describe('Lever-style resume upload then pre-fill', () => {
342
- it('uploads a resume and waits for parsing banner to disappear', async () => {
343
- // Simulate post-upload state with parsing banner visible
344
- mockState.currentA11yRoot = node('group', undefined, {
345
- meta: { pageUrl: 'https://jobs.lever.co/acme/apply', scrollX: 0, scrollY: 0 },
346
- children: [
347
- node('button', 'Upload Resume/CV', { path: [0] }),
348
- node('alert', 'Parsing your resume...', { path: [1] }),
349
- node('textbox', 'Full name', { value: '', path: [2], state: { required: true } }),
350
- node('textbox', 'Email', { value: '', path: [3], state: { required: true } }),
351
- node('textbox', 'Phone', { value: '', path: [4] }),
352
- ],
353
- });
354
- // Upload the file
355
- const uploadHandler = getToolHandler('geometra_upload_files');
356
- const uploadResult = await uploadHandler({
357
- fieldLabel: 'Upload Resume/CV',
358
- paths: ['/tmp/resume.pdf'],
359
- detail: 'terse',
360
- });
361
- const uploadPayload = JSON.parse(uploadResult.content[0].text);
362
- expect(uploadPayload).toMatchObject({ fileCount: 1, fieldLabel: 'Upload Resume/CV' });
363
- // Wait for parsing banner to disappear
364
- const waitHandler = getToolHandler('geometra_wait_for_resume_parse');
365
- const waitResult = await waitHandler({ text: 'Parsing', timeoutMs: 30000 });
366
- expect(waitResult.content[0].text).toContain('condition satisfied');
367
- // After parsing, fields are pre-filled from resume
368
- bumpMockUiRevision();
369
- mockState.currentA11yRoot = node('group', undefined, {
370
- meta: { pageUrl: 'https://jobs.lever.co/acme/apply', scrollX: 0, scrollY: 0 },
371
- children: [
372
- node('button', 'Upload Resume/CV', { path: [0], value: 'resume.pdf' }),
373
- node('textbox', 'Full name', { value: 'Taylor Smith', path: [2], state: { required: true } }),
374
- node('textbox', 'Email', { value: 'taylor@example.com', path: [3], state: { required: true } }),
375
- node('textbox', 'Phone', { value: '(555) 987-6543', path: [4] }),
376
- ],
377
- });
378
- // Now fill with skipPreFilled to avoid overwriting parsed data
379
- mockState.formSchemas = [{
380
- formId: 'fm:0',
381
- name: 'Application',
382
- fieldCount: 4,
383
- requiredCount: 2,
384
- invalidCount: 0,
385
- fields: [
386
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true, value: 'Taylor Smith' },
387
- { id: 'ff:0.1', kind: 'text', label: 'Email', required: true, value: 'taylor@example.com' },
388
- { id: 'ff:0.2', kind: 'text', label: 'Phone', value: '(555) 987-6543' },
389
- { id: 'ff:0.3', kind: 'text', label: 'LinkedIn URL' },
390
- ],
391
- }];
392
- const fillHandler = getToolHandler('geometra_fill_form');
393
- const fillResult = await fillHandler({
394
- formId: 'fm:0',
395
- valuesById: {
396
- 'ff:0.0': 'Taylor Smith',
397
- 'ff:0.1': 'taylor@example.com',
398
- 'ff:0.2': '(555) 987-6543',
399
- 'ff:0.3': 'https://linkedin.com/in/taylorsmith',
400
- },
401
- skipPreFilled: true,
402
- stopOnError: true,
403
- failOnInvalid: false,
404
- includeSteps: true,
405
- detail: 'minimal',
406
- });
407
- const payload = JSON.parse(fillResult.content[0].text);
408
- expect(payload).toMatchObject({ completed: true });
409
- // Three fields already matched their intended values
410
- expect(payload).toHaveProperty('skippedPreFilled', 3);
411
- });
412
- });
413
- // ── 4. Ashby-style ───────────────────────────────────────────────
414
- describe('Ashby-style custom controls', () => {
415
- beforeEach(() => {
416
- mockState.formSchemas = [{
417
- formId: 'fm:0',
418
- name: 'Application Form',
419
- fieldCount: 4,
420
- requiredCount: 2,
421
- invalidCount: 0,
422
- fields: [
423
- { id: 'ff:0.0', kind: 'text', label: 'Full Name', required: true },
424
- { id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
425
- { id: 'ff:0.2', kind: 'toggle', label: 'I agree to the Privacy Policy', controlType: 'checkbox' },
426
- { id: 'ff:0.3', kind: 'choice', label: 'How did you hear about us?', choiceType: 'combobox', optionCount: 8 },
427
- ],
428
- }];
429
- mockState.currentA11yRoot = node('group', undefined, {
430
- meta: { pageUrl: 'https://jobs.ashbyhq.com/acme/apply', scrollX: 0, scrollY: 0 },
431
- children: [
432
- node('textbox', 'Full Name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
433
- node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
434
- node('checkbox', 'I agree to the Privacy Policy', { path: [2], state: { checked: false } }),
435
- node('combobox', 'How did you hear about us?', { value: '', path: [3] }),
436
- ],
437
- });
438
- });
439
- it('uses set_checked for visually hidden custom checkboxes', async () => {
440
- const handler = getToolHandler('geometra_set_checked');
441
- const result = await handler({
442
- label: 'I agree to the Privacy Policy',
443
- checked: true,
444
- controlType: 'checkbox',
445
- detail: 'terse',
446
- });
447
- const payload = JSON.parse(result.content[0].text);
448
- expect(payload).toMatchObject({ label: 'I agree to the Privacy Policy', checked: true, controlType: 'checkbox' });
449
- expect(mockState.sendSetChecked).toHaveBeenCalledWith(mockState.session, 'I agree to the Privacy Policy', { checked: true, exact: undefined, controlType: 'checkbox' }, undefined);
450
- });
451
- it('uses pick_listbox_option for combobox dropdowns', async () => {
452
- const handler = getToolHandler('geometra_pick_listbox_option');
453
- const result = await handler({
454
- fieldLabel: 'How did you hear about us?',
455
- label: 'LinkedIn',
456
- detail: 'terse',
457
- });
458
- const payload = JSON.parse(result.content[0].text);
459
- expect(payload).toMatchObject({ label: 'LinkedIn', fieldLabel: 'How did you hear about us?' });
460
- expect(mockState.sendListboxPick).toHaveBeenCalledWith(mockState.session, 'LinkedIn', expect.objectContaining({
461
- fieldLabel: 'How did you hear about us?',
462
- }), undefined);
463
- });
464
- it('fills text fields and toggles together via fill_fields', async () => {
465
- const handler = getToolHandler('geometra_fill_fields');
466
- const result = await handler({
467
- fields: [
468
- { kind: 'text', fieldId: 'ff:0.0', value: 'Taylor Smith' },
469
- { kind: 'text', fieldId: 'ff:0.1', value: 'taylor@example.com' },
470
- { kind: 'toggle', fieldId: 'ff:0.2', checked: true },
471
- ],
472
- stopOnError: true,
473
- failOnInvalid: false,
474
- includeSteps: true,
475
- detail: 'minimal',
476
- });
477
- const payload = JSON.parse(result.content[0].text);
478
- expect(payload).toMatchObject({
479
- completed: true,
480
- fieldCount: 3,
481
- successCount: 3,
482
- errorCount: 0,
483
- });
484
- expect(mockState.sendFieldText).toHaveBeenCalledTimes(2);
485
- expect(mockState.sendSetChecked).toHaveBeenCalledTimes(1);
486
- });
487
- });
488
- // ── 5. Multi-page flow ───────────────────────────────────────────
489
- describe('multi-page application flow', () => {
490
- it('tracks workflow state across page navigations', async () => {
491
- // Page 1: Personal info
492
- mockState.formSchemas = [{
493
- formId: 'fm:0',
494
- name: 'Personal Info',
495
- fieldCount: 2,
496
- requiredCount: 2,
497
- invalidCount: 0,
498
- fields: [
499
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
500
- { id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
501
- ],
502
- }];
503
- mockState.currentA11yRoot = node('group', undefined, {
504
- meta: { pageUrl: 'https://careers.acme.com/apply/step1', scrollX: 0, scrollY: 0 },
505
- children: [
506
- node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
507
- node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
508
- node('button', 'Next', { path: [2] }),
509
- ],
510
- });
511
- // Fill page 1
512
- const fillHandler = getToolHandler('geometra_fill_form');
513
- const fillResult1 = await fillHandler({
514
- formId: 'fm:0',
515
- valuesById: {
516
- 'ff:0.0': 'Taylor Smith',
517
- 'ff:0.1': 'taylor@example.com',
518
- },
519
- stopOnError: true,
520
- failOnInvalid: false,
521
- includeSteps: false,
522
- detail: 'terse',
523
- });
524
- const payload1 = JSON.parse(fillResult1.content[0].text);
525
- expect(payload1).toMatchObject({ completed: true });
526
- // Click Next -> page changes
527
- bumpMockUiRevision();
528
- mockState.formSchemas = [{
529
- formId: 'fm:1',
530
- name: 'Experience',
531
- fieldCount: 2,
532
- requiredCount: 1,
533
- invalidCount: 0,
534
- fields: [
535
- { id: 'ff:1.0', kind: 'text', label: 'Current Company', required: true },
536
- { id: 'ff:1.1', kind: 'text', label: 'Years of Experience' },
537
- ],
538
- }];
539
- mockState.currentA11yRoot = node('group', undefined, {
540
- meta: { pageUrl: 'https://careers.acme.com/apply/step2', scrollX: 0, scrollY: 0 },
541
- children: [
542
- node('textbox', 'Current Company', { value: 'Acme Corp', path: [0], state: { required: true } }),
543
- node('textbox', 'Years of Experience', { value: '5', path: [1] }),
544
- node('button', 'Submit', { path: [2] }),
545
- ],
546
- });
547
- // Fill page 2
548
- const fillResult2 = await fillHandler({
549
- formId: 'fm:1',
550
- valuesById: {
551
- 'ff:1.0': 'Acme Corp',
552
- 'ff:1.1': '5',
553
- },
554
- stopOnError: true,
555
- failOnInvalid: false,
556
- includeSteps: false,
557
- detail: 'terse',
558
- });
559
- const payload2 = JSON.parse(fillResult2.content[0].text);
560
- expect(payload2).toMatchObject({ completed: true });
561
- // Check workflow state tracks both pages
562
- const stateHandler = getToolHandler('geometra_workflow_state');
563
- const stateResult = await stateHandler({});
564
- const statePayload = JSON.parse(stateResult.content[0].text);
565
- expect(statePayload.pageCount).toBe(2);
566
- expect(statePayload.totalFieldsFilled).toBeGreaterThanOrEqual(4);
567
- });
568
- });
569
- // ── 6. CAPTCHA detection ─────────────────────────────────────────
570
- describe('CAPTCHA detection', () => {
571
- it('surfaces captchaDetected when reCAPTCHA iframe is in the tree', async () => {
572
- // Override buildPageModel to return captcha detection
573
- const { buildPageModel } = await import('../session.js');
574
- const mockBuildPageModel = buildPageModel;
575
- mockBuildPageModel.mockReturnValueOnce({
576
- viewport: { width: 1280, height: 800 },
577
- archetypes: ['form'],
578
- summary: { landmarkCount: 0, formCount: 1, dialogCount: 0, listCount: 0, focusableCount: 4 },
579
- captcha: { detected: true, type: 'recaptcha', hint: 'Google reCAPTCHA detected' },
580
- primaryActions: [],
581
- landmarks: [],
582
- forms: [],
583
- dialogs: [],
584
- lists: [],
585
- });
586
- mockState.currentA11yRoot = node('group', undefined, {
587
- meta: { pageUrl: 'https://boards.greenhouse.io/apply', scrollX: 0, scrollY: 0 },
588
- children: [
589
- node('textbox', 'Email', { value: '', path: [0], state: { required: true } }),
590
- node('group', 'reCAPTCHA', {
591
- path: [1],
592
- meta: { pageUrl: 'https://boards.greenhouse.io/apply' },
593
- children: [
594
- node('checkbox', 'I\'m not a robot', { path: [1, 0] }),
595
- ],
596
- }),
597
- node('button', 'Submit', { path: [2] }),
598
- ],
599
- });
600
- const handler = getToolHandler('geometra_page_model');
601
- const result = await handler({});
602
- const payload = JSON.parse(result.content[0].text);
603
- expect(payload).toMatchObject({
604
- captcha: {
605
- detected: true,
606
- type: 'recaptcha',
607
- hint: 'Google reCAPTCHA detected',
608
- },
609
- });
610
- });
611
- });
612
- // ── 7. Date/phone normalization ──────────────────────────────────
613
- describe('date and phone normalization', () => {
614
- beforeEach(() => {
615
- mockState.formSchemas = [{
616
- formId: 'fm:0',
617
- name: 'Application',
618
- fieldCount: 3,
619
- requiredCount: 2,
620
- invalidCount: 0,
621
- fields: [
622
- { id: 'ff:0.0', kind: 'text', label: 'Start Date', required: true, format: { placeholder: 'MM/DD/YYYY', inputType: 'text' } },
623
- { id: 'ff:0.1', kind: 'text', label: 'Phone', required: true, format: { placeholder: '(555) 123-4567', inputType: 'tel' } },
624
- { id: 'ff:0.2', kind: 'text', label: 'Full name' },
625
- ],
626
- }];
627
- mockState.currentA11yRoot = node('group', undefined, {
628
- meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
629
- children: [
630
- node('textbox', 'Start Date', {
631
- value: '03/15/2025',
632
- path: [0],
633
- state: { required: true },
634
- meta: { placeholder: 'MM/DD/YYYY' },
635
- }),
636
- node('textbox', 'Phone', {
637
- value: '(555) 987-6543',
638
- path: [1],
639
- state: { required: true },
640
- meta: { placeholder: '(555) 123-4567' },
641
- }),
642
- node('textbox', 'Full name', { value: 'Taylor Smith', path: [2] }),
643
- ],
644
- });
645
- });
646
- it('sends date and phone values as-is through fill_form (server normalizes)', async () => {
647
- const handler = getToolHandler('geometra_fill_form');
648
- const result = await handler({
649
- formId: 'fm:0',
650
- valuesById: {
651
- 'ff:0.0': '03/15/2025',
652
- 'ff:0.1': '(555) 987-6543',
653
- 'ff:0.2': 'Taylor Smith',
654
- },
655
- stopOnError: true,
656
- failOnInvalid: false,
657
- includeSteps: true,
658
- detail: 'minimal',
659
- });
660
- const payload = JSON.parse(result.content[0].text);
661
- expect(payload).toMatchObject({
662
- completed: true,
663
- fieldCount: 3,
664
- successCount: 3,
665
- errorCount: 0,
666
- });
667
- // The text handler should have been called with the formatted values
668
- expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Start Date', '03/15/2025', expect.objectContaining({ fieldId: 'ff:0.0' }), undefined);
669
- expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Phone', '(555) 987-6543', expect.objectContaining({ fieldId: 'ff:0.1' }), undefined);
670
- });
671
- it('exposes format hints in the form schema for agent consumption', async () => {
672
- const handler = getToolHandler('geometra_form_schema');
673
- const result = await handler({});
674
- const payload = JSON.parse(result.content[0].text);
675
- expect(payload).toHaveProperty('forms');
676
- });
677
- });
678
- // ── 8. Error recovery ────────────────────────────────────────────
679
- describe('error recovery with suggestion', () => {
680
- it('populates suggestion when a choice field fill fails', async () => {
681
- mockState.formSchemas = [{
682
- formId: 'fm:0',
683
- name: 'Application',
684
- fieldCount: 2,
685
- requiredCount: 1,
686
- invalidCount: 0,
687
- fields: [
688
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
689
- { id: 'ff:0.1', kind: 'choice', label: 'Department', choiceType: 'select', optionCount: 5 },
690
- ],
691
- }];
692
- mockState.currentA11yRoot = node('group', undefined, {
693
- meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
694
- children: [
695
- node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
696
- node('combobox', 'Department', { value: '', path: [1] }),
697
- ],
698
- });
699
- // Make the choice fill throw a "no option found" error
700
- mockState.sendFieldChoice.mockRejectedValueOnce(new Error('No option found matching "Engineering"'));
701
- const handler = getToolHandler('geometra_fill_form');
702
- const result = await handler({
703
- formId: 'fm:0',
704
- valuesById: {
705
- 'ff:0.0': 'Taylor Smith',
706
- 'ff:0.1': 'Engineering',
707
- },
708
- stopOnError: true,
709
- failOnInvalid: false,
710
- includeSteps: true,
711
- detail: 'minimal',
712
- });
713
- const payload = JSON.parse(result.content[0].text);
714
- const steps = payload.steps;
715
- expect(payload.errorCount).toBeGreaterThan(0);
716
- // Find the failed step with suggestion
717
- const failedStep = steps.find(s => !s.ok);
718
- expect(failedStep).toBeDefined();
719
- expect(failedStep.error).toContain('No option found');
720
- expect(failedStep.suggestion).toContain('geometra_pick_listbox_option');
721
- });
722
- });
723
- // ── 9. Confidence scoring ────────────────────────────────────────
724
- describe('confidence scoring', () => {
725
- it('returns minConfidence 1.0 for all valuesById entries', async () => {
726
- mockState.formSchemas = [{
727
- formId: 'fm:0',
728
- name: 'Application',
729
- fieldCount: 3,
730
- requiredCount: 3,
731
- invalidCount: 0,
732
- fields: [
733
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
734
- { id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
735
- { id: 'ff:0.2', kind: 'text', label: 'Phone', required: true },
736
- ],
737
- }];
738
- mockState.currentA11yRoot = node('group', undefined, {
739
- meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
740
- children: [
741
- node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
742
- node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
743
- node('textbox', 'Phone', { value: '5559876543', path: [2], state: { required: true } }),
744
- ],
745
- });
746
- const handler = getToolHandler('geometra_fill_form');
747
- const result = await handler({
748
- formId: 'fm:0',
749
- valuesById: {
750
- 'ff:0.0': 'Taylor Smith',
751
- 'ff:0.1': 'taylor@example.com',
752
- 'ff:0.2': '5559876543',
753
- },
754
- stopOnError: true,
755
- failOnInvalid: false,
756
- includeSteps: true,
757
- detail: 'minimal',
758
- });
759
- const payload = JSON.parse(result.content[0].text);
760
- expect(payload.minConfidence).toBe(1.0);
761
- const steps = payload.steps;
762
- for (const step of steps) {
763
- expect(step.confidence).toBe(1.0);
764
- expect(step.matchMethod).toBe('id');
765
- }
766
- });
767
- it('returns lower confidence for valuesByLabel with exact match', async () => {
768
- mockState.formSchemas = [{
769
- formId: 'fm:0',
770
- name: 'Application',
771
- fieldCount: 2,
772
- requiredCount: 2,
773
- invalidCount: 0,
774
- fields: [
775
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
776
- { id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
777
- ],
778
- }];
779
- mockState.currentA11yRoot = node('group', undefined, {
780
- meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
781
- children: [
782
- node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
783
- node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
784
- ],
785
- });
786
- const handler = getToolHandler('geometra_fill_form');
787
- const result = await handler({
788
- formId: 'fm:0',
789
- valuesByLabel: {
790
- 'Full name': 'Taylor Smith',
791
- 'Email': 'taylor@example.com',
792
- },
793
- stopOnError: true,
794
- failOnInvalid: false,
795
- includeSteps: true,
796
- detail: 'minimal',
797
- });
798
- const payload = JSON.parse(result.content[0].text);
799
- expect(payload.minConfidence).toBe(0.95);
800
- const steps = payload.steps;
801
- for (const step of steps) {
802
- expect(step.confidence).toBe(0.95);
803
- expect(step.matchMethod).toBe('label-exact');
804
- }
805
- });
806
- it('returns 0.8 confidence for normalized label matches', async () => {
807
- mockState.formSchemas = [{
808
- formId: 'fm:0',
809
- name: 'Application',
810
- fieldCount: 2,
811
- requiredCount: 2,
812
- invalidCount: 0,
813
- fields: [
814
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
815
- { id: 'ff:0.1', kind: 'text', label: 'Email Address', required: true },
816
- ],
817
- }];
818
- mockState.currentA11yRoot = node('group', undefined, {
819
- meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
820
- children: [
821
- node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
822
- node('textbox', 'Email Address', { value: 'taylor@example.com', path: [1], state: { required: true } }),
823
- ],
824
- });
825
- const handler = getToolHandler('geometra_fill_form');
826
- const result = await handler({
827
- formId: 'fm:0',
828
- valuesByLabel: {
829
- 'full name': 'Taylor Smith',
830
- 'email address': 'taylor@example.com',
831
- },
832
- stopOnError: true,
833
- failOnInvalid: false,
834
- includeSteps: true,
835
- detail: 'minimal',
836
- });
837
- const payload = JSON.parse(result.content[0].text);
838
- // Normalized labels should give lower confidence
839
- expect(payload.minConfidence).toBeLessThanOrEqual(0.95);
840
- });
841
- it('mixes valuesById (1.0) and valuesByLabel (0.95) and reports minimum', async () => {
842
- mockState.formSchemas = [{
843
- formId: 'fm:0',
844
- name: 'Application',
845
- fieldCount: 3,
846
- requiredCount: 3,
847
- invalidCount: 0,
848
- fields: [
849
- { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
850
- { id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
851
- { id: 'ff:0.2', kind: 'text', label: 'Phone', required: true },
852
- ],
853
- }];
854
- mockState.currentA11yRoot = node('group', undefined, {
855
- meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
856
- children: [
857
- node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
858
- node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
859
- node('textbox', 'Phone', { value: '5559876543', path: [2], state: { required: true } }),
860
- ],
861
- });
862
- const handler = getToolHandler('geometra_fill_form');
863
- const result = await handler({
864
- formId: 'fm:0',
865
- valuesById: {
866
- 'ff:0.0': 'Taylor Smith',
867
- },
868
- valuesByLabel: {
869
- 'Email': 'taylor@example.com',
870
- 'Phone': '5559876543',
871
- },
872
- stopOnError: true,
873
- failOnInvalid: false,
874
- includeSteps: true,
875
- detail: 'minimal',
876
- });
877
- const payload = JSON.parse(result.content[0].text);
878
- // minConfidence should be 0.95 (from the label-exact matches), not 1.0
879
- expect(payload.minConfidence).toBe(0.95);
880
- const steps = payload.steps;
881
- const idStep = steps.find(s => s.matchMethod === 'id');
882
- const labelSteps = steps.filter(s => s.matchMethod === 'label-exact');
883
- expect(idStep).toBeDefined();
884
- expect(idStep.confidence).toBe(1.0);
885
- expect(labelSteps.length).toBe(2);
886
- for (const ls of labelSteps) {
887
- expect(ls.confidence).toBe(0.95);
888
- }
889
- });
890
- });
891
- });