@geometra/mcp 1.22.0 → 1.23.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.
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,886 @@
|
|
|
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
|
+
getSession: vi.fn(() => mockState.session),
|
|
74
|
+
sendClick: mockState.sendClick,
|
|
75
|
+
sendType: mockState.sendType,
|
|
76
|
+
sendKey: mockState.sendKey,
|
|
77
|
+
sendFileUpload: mockState.sendFileUpload,
|
|
78
|
+
sendFieldText: mockState.sendFieldText,
|
|
79
|
+
sendFieldChoice: mockState.sendFieldChoice,
|
|
80
|
+
sendFillFields: mockState.sendFillFields,
|
|
81
|
+
sendListboxPick: mockState.sendListboxPick,
|
|
82
|
+
sendSelectOption: mockState.sendSelectOption,
|
|
83
|
+
sendSetChecked: mockState.sendSetChecked,
|
|
84
|
+
sendWheel: mockState.sendWheel,
|
|
85
|
+
buildA11yTree: vi.fn(() => mockState.currentA11yRoot),
|
|
86
|
+
buildCompactUiIndex: vi.fn(() => ({ nodes: [], context: {} })),
|
|
87
|
+
buildPageModel: vi.fn(() => ({
|
|
88
|
+
viewport: { width: 1280, height: 800 },
|
|
89
|
+
archetypes: ['form'],
|
|
90
|
+
summary: { landmarkCount: 0, formCount: 1, dialogCount: 0, listCount: 0, focusableCount: 6 },
|
|
91
|
+
primaryActions: [],
|
|
92
|
+
landmarks: [],
|
|
93
|
+
forms: [],
|
|
94
|
+
dialogs: [],
|
|
95
|
+
lists: [],
|
|
96
|
+
})),
|
|
97
|
+
buildFormSchemas: vi.fn(() => mockState.formSchemas),
|
|
98
|
+
buildFormRequiredSnapshot: vi.fn(() => []),
|
|
99
|
+
expandPageSection: vi.fn(() => null),
|
|
100
|
+
buildUiDelta: vi.fn(() => ({})),
|
|
101
|
+
hasUiDelta: vi.fn(() => false),
|
|
102
|
+
nodeIdForPath: vi.fn((path) => `n:${path.length > 0 ? path.join('.') : 'root'}`),
|
|
103
|
+
summarizeCompactIndex: vi.fn(() => ''),
|
|
104
|
+
summarizePageModel: vi.fn(() => ''),
|
|
105
|
+
summarizeUiDelta: vi.fn(() => ''),
|
|
106
|
+
nodeContextForNode: vi.fn((_, nd) => mockState.nodeContexts.get((nd.path ?? []).join('.'))),
|
|
107
|
+
waitForUiCondition: mockState.waitForUiCondition,
|
|
108
|
+
}));
|
|
109
|
+
const { createServer } = await import('../server.js');
|
|
110
|
+
function getToolHandler(name) {
|
|
111
|
+
const server = createServer();
|
|
112
|
+
return server._registeredTools[name].handler;
|
|
113
|
+
}
|
|
114
|
+
// ── Shared beforeEach ────────────────────────────────────────────────
|
|
115
|
+
describe('ATS integration patterns', () => {
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
vi.clearAllMocks();
|
|
118
|
+
resetMockSessionCaches();
|
|
119
|
+
mockState.connect.mockResolvedValue(mockState.session);
|
|
120
|
+
mockState.connectThroughProxy.mockResolvedValue(mockState.session);
|
|
121
|
+
mockState.prewarmProxy.mockResolvedValue({
|
|
122
|
+
prepared: true,
|
|
123
|
+
reused: false,
|
|
124
|
+
transport: 'embedded',
|
|
125
|
+
pageUrl: 'https://boards.greenhouse.io/apply',
|
|
126
|
+
wsUrl: 'ws://127.0.0.1:3200',
|
|
127
|
+
headless: true,
|
|
128
|
+
width: 1280,
|
|
129
|
+
height: 720,
|
|
130
|
+
});
|
|
131
|
+
mockState.formSchemas = [];
|
|
132
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
133
|
+
meta: { pageUrl: 'https://boards.greenhouse.io/apply', scrollX: 0, scrollY: 0 },
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
// ── 1. Greenhouse-style ──────────────────────────────────────────
|
|
137
|
+
describe('Greenhouse-style simple form', () => {
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
mockState.formSchemas = [{
|
|
140
|
+
formId: 'fm:0',
|
|
141
|
+
name: 'Application',
|
|
142
|
+
fieldCount: 5,
|
|
143
|
+
requiredCount: 3,
|
|
144
|
+
invalidCount: 0,
|
|
145
|
+
fields: [
|
|
146
|
+
{ id: 'ff:0.0', kind: 'text', label: 'First name', required: true },
|
|
147
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Last name', required: true },
|
|
148
|
+
{ id: 'ff:0.2', kind: 'text', label: 'Email', required: true },
|
|
149
|
+
{ id: 'ff:0.3', kind: 'choice', label: 'Location', choiceType: 'combobox', optionCount: 15 },
|
|
150
|
+
{ id: 'ff:0.4', kind: 'choice', label: 'Are you legally authorized to work in the United States?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
|
|
151
|
+
],
|
|
152
|
+
}];
|
|
153
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
154
|
+
meta: { pageUrl: 'https://boards.greenhouse.io/apply', scrollX: 0, scrollY: 0 },
|
|
155
|
+
children: [
|
|
156
|
+
node('textbox', 'First name', { value: 'Taylor', path: [0], state: { required: true } }),
|
|
157
|
+
node('textbox', 'Last name', { value: 'Smith', path: [1], state: { required: true } }),
|
|
158
|
+
node('textbox', 'Email', { value: 'taylor@example.com', path: [2], state: { required: true } }),
|
|
159
|
+
node('combobox', 'Location', { value: 'San Francisco, CA', path: [3] }),
|
|
160
|
+
node('radio', 'Yes', { path: [4, 0], state: { checked: true } }),
|
|
161
|
+
node('radio', 'No', { path: [4, 1], state: { checked: false } }),
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
it('discovers form_schema and fills with valuesById', async () => {
|
|
166
|
+
const schemaHandler = getToolHandler('geometra_form_schema');
|
|
167
|
+
const schemaResult = await schemaHandler({});
|
|
168
|
+
const schemaPayload = JSON.parse(schemaResult.content[0].text);
|
|
169
|
+
expect(schemaPayload).toHaveProperty('forms');
|
|
170
|
+
const fillHandler = getToolHandler('geometra_fill_form');
|
|
171
|
+
const fillResult = await fillHandler({
|
|
172
|
+
formId: 'fm:0',
|
|
173
|
+
valuesById: {
|
|
174
|
+
'ff:0.0': 'Taylor',
|
|
175
|
+
'ff:0.1': 'Smith',
|
|
176
|
+
'ff:0.2': 'taylor@example.com',
|
|
177
|
+
'ff:0.3': 'San Francisco, CA',
|
|
178
|
+
'ff:0.4': 'Yes',
|
|
179
|
+
},
|
|
180
|
+
stopOnError: true,
|
|
181
|
+
failOnInvalid: false,
|
|
182
|
+
includeSteps: true,
|
|
183
|
+
detail: 'minimal',
|
|
184
|
+
});
|
|
185
|
+
const payload = JSON.parse(fillResult.content[0].text);
|
|
186
|
+
expect(payload).toMatchObject({
|
|
187
|
+
completed: true,
|
|
188
|
+
fieldCount: 5,
|
|
189
|
+
successCount: 5,
|
|
190
|
+
errorCount: 0,
|
|
191
|
+
});
|
|
192
|
+
expect(payload.minConfidence).toBe(1.0);
|
|
193
|
+
});
|
|
194
|
+
it('verifies fills by reading back field values after completion', async () => {
|
|
195
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
196
|
+
const result = await handler({
|
|
197
|
+
formId: 'fm:0',
|
|
198
|
+
valuesById: {
|
|
199
|
+
'ff:0.0': 'Taylor',
|
|
200
|
+
'ff:0.1': 'Smith',
|
|
201
|
+
'ff:0.2': 'taylor@example.com',
|
|
202
|
+
},
|
|
203
|
+
stopOnError: true,
|
|
204
|
+
failOnInvalid: false,
|
|
205
|
+
includeSteps: true,
|
|
206
|
+
verifyFills: true,
|
|
207
|
+
detail: 'minimal',
|
|
208
|
+
});
|
|
209
|
+
const payload = JSON.parse(result.content[0].text);
|
|
210
|
+
expect(payload).toMatchObject({ completed: true, successCount: 3, errorCount: 0 });
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
// ── 2. Workday-style ─────────────────────────────────────────────
|
|
214
|
+
describe('Workday-style multi-section form', () => {
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
mockState.formSchemas = [{
|
|
217
|
+
formId: 'fm:0',
|
|
218
|
+
name: 'Job Application',
|
|
219
|
+
fieldCount: 8,
|
|
220
|
+
requiredCount: 5,
|
|
221
|
+
invalidCount: 0,
|
|
222
|
+
fields: [
|
|
223
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Legal First Name', required: true },
|
|
224
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Legal Last Name', required: true },
|
|
225
|
+
{ id: 'ff:0.2', kind: 'text', label: 'Email Address', required: true },
|
|
226
|
+
{ id: 'ff:0.3', kind: 'text', label: 'Phone Number', required: true, format: { placeholder: '(555) 123-4567' } },
|
|
227
|
+
{ id: 'ff:0.4', kind: 'text', label: 'Job Title', required: false },
|
|
228
|
+
{ id: 'ff:0.5', kind: 'text', label: 'Company', required: false },
|
|
229
|
+
{ id: 'ff:0.6', kind: 'text', label: 'School or University', required: true },
|
|
230
|
+
{ id: 'ff:0.7', kind: 'choice', label: 'Degree', choiceType: 'select', optionCount: 8 },
|
|
231
|
+
],
|
|
232
|
+
sections: [
|
|
233
|
+
{ name: 'Personal Information', fieldIds: ['ff:0.0', 'ff:0.1', 'ff:0.2', 'ff:0.3'] },
|
|
234
|
+
{ name: 'Work Experience', fieldIds: ['ff:0.4', 'ff:0.5'] },
|
|
235
|
+
{ name: 'Education', fieldIds: ['ff:0.6', 'ff:0.7'] },
|
|
236
|
+
],
|
|
237
|
+
}];
|
|
238
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
239
|
+
meta: { pageUrl: 'https://myworkday.com/apply', scrollX: 0, scrollY: 0 },
|
|
240
|
+
children: [
|
|
241
|
+
node('group', 'Personal Information', {
|
|
242
|
+
path: [0],
|
|
243
|
+
children: [
|
|
244
|
+
node('textbox', 'Legal First Name', { value: 'Taylor', path: [0, 0], state: { required: true } }),
|
|
245
|
+
node('textbox', 'Legal Last Name', { value: 'Smith', path: [0, 1], state: { required: true } }),
|
|
246
|
+
node('textbox', 'Email Address', { value: 'taylor@example.com', path: [0, 2], state: { required: true } }),
|
|
247
|
+
node('textbox', 'Phone Number', {
|
|
248
|
+
value: '(555) 987-6543',
|
|
249
|
+
path: [0, 3],
|
|
250
|
+
state: { required: true },
|
|
251
|
+
meta: { placeholder: '(555) 123-4567' },
|
|
252
|
+
}),
|
|
253
|
+
],
|
|
254
|
+
}),
|
|
255
|
+
node('group', 'Work Experience', {
|
|
256
|
+
path: [1],
|
|
257
|
+
children: [
|
|
258
|
+
node('textbox', 'Job Title', { value: 'Software Engineer', path: [1, 0] }),
|
|
259
|
+
node('textbox', 'Company', { value: 'Acme Corp', path: [1, 1] }),
|
|
260
|
+
],
|
|
261
|
+
}),
|
|
262
|
+
node('group', 'Education', {
|
|
263
|
+
path: [2],
|
|
264
|
+
children: [
|
|
265
|
+
node('textbox', 'School or University', { value: 'MIT', path: [2, 0], state: { required: true } }),
|
|
266
|
+
node('combobox', 'Degree', { value: "Bachelor's", path: [2, 1] }),
|
|
267
|
+
],
|
|
268
|
+
}),
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
it('fills required fields only using onlyRequiredFields discovery', async () => {
|
|
273
|
+
const schemaHandler = getToolHandler('geometra_form_schema');
|
|
274
|
+
const schemaResult = await schemaHandler({ onlyRequiredFields: true });
|
|
275
|
+
const schemaPayload = JSON.parse(schemaResult.content[0].text);
|
|
276
|
+
expect(schemaPayload).toHaveProperty('forms');
|
|
277
|
+
const fillHandler = getToolHandler('geometra_fill_form');
|
|
278
|
+
const result = await fillHandler({
|
|
279
|
+
formId: 'fm:0',
|
|
280
|
+
valuesById: {
|
|
281
|
+
'ff:0.0': 'Taylor',
|
|
282
|
+
'ff:0.1': 'Smith',
|
|
283
|
+
'ff:0.2': 'taylor@example.com',
|
|
284
|
+
'ff:0.3': '(555) 987-6543',
|
|
285
|
+
'ff:0.6': 'MIT',
|
|
286
|
+
},
|
|
287
|
+
stopOnError: true,
|
|
288
|
+
failOnInvalid: false,
|
|
289
|
+
includeSteps: true,
|
|
290
|
+
detail: 'minimal',
|
|
291
|
+
});
|
|
292
|
+
const payload = JSON.parse(result.content[0].text);
|
|
293
|
+
expect(payload).toMatchObject({
|
|
294
|
+
completed: true,
|
|
295
|
+
fieldCount: 5,
|
|
296
|
+
successCount: 5,
|
|
297
|
+
errorCount: 0,
|
|
298
|
+
minConfidence: 1.0,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
it('skips pre-filled fields when skipPreFilled is true', async () => {
|
|
302
|
+
// Set up schema where some fields already have matching values
|
|
303
|
+
mockState.formSchemas = [{
|
|
304
|
+
formId: 'fm:0',
|
|
305
|
+
name: 'Job Application',
|
|
306
|
+
fieldCount: 3,
|
|
307
|
+
requiredCount: 3,
|
|
308
|
+
invalidCount: 0,
|
|
309
|
+
fields: [
|
|
310
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Legal First Name', required: true, value: 'Taylor' },
|
|
311
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Legal Last Name', required: true, value: 'Smith' },
|
|
312
|
+
{ id: 'ff:0.2', kind: 'text', label: 'Email Address', required: true },
|
|
313
|
+
],
|
|
314
|
+
}];
|
|
315
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
316
|
+
const result = await handler({
|
|
317
|
+
formId: 'fm:0',
|
|
318
|
+
valuesById: {
|
|
319
|
+
'ff:0.0': 'Taylor',
|
|
320
|
+
'ff:0.1': 'Smith',
|
|
321
|
+
'ff:0.2': 'taylor@example.com',
|
|
322
|
+
},
|
|
323
|
+
stopOnError: true,
|
|
324
|
+
failOnInvalid: false,
|
|
325
|
+
includeSteps: true,
|
|
326
|
+
skipPreFilled: true,
|
|
327
|
+
detail: 'minimal',
|
|
328
|
+
});
|
|
329
|
+
const payload = JSON.parse(result.content[0].text);
|
|
330
|
+
expect(payload).toMatchObject({ completed: true });
|
|
331
|
+
// First name and last name were already filled with matching values, so they should be skipped
|
|
332
|
+
expect(payload).toHaveProperty('skippedPreFilled', 2);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
// ── 3. Lever-style ───────────────────────────────────────────────
|
|
336
|
+
describe('Lever-style resume upload then pre-fill', () => {
|
|
337
|
+
it('uploads a resume and waits for parsing banner to disappear', async () => {
|
|
338
|
+
// Simulate post-upload state with parsing banner visible
|
|
339
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
340
|
+
meta: { pageUrl: 'https://jobs.lever.co/acme/apply', scrollX: 0, scrollY: 0 },
|
|
341
|
+
children: [
|
|
342
|
+
node('button', 'Upload Resume/CV', { path: [0] }),
|
|
343
|
+
node('alert', 'Parsing your resume...', { path: [1] }),
|
|
344
|
+
node('textbox', 'Full name', { value: '', path: [2], state: { required: true } }),
|
|
345
|
+
node('textbox', 'Email', { value: '', path: [3], state: { required: true } }),
|
|
346
|
+
node('textbox', 'Phone', { value: '', path: [4] }),
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
// Upload the file
|
|
350
|
+
const uploadHandler = getToolHandler('geometra_upload_files');
|
|
351
|
+
const uploadResult = await uploadHandler({
|
|
352
|
+
fieldLabel: 'Upload Resume/CV',
|
|
353
|
+
paths: ['/tmp/resume.pdf'],
|
|
354
|
+
detail: 'terse',
|
|
355
|
+
});
|
|
356
|
+
const uploadPayload = JSON.parse(uploadResult.content[0].text);
|
|
357
|
+
expect(uploadPayload).toMatchObject({ fileCount: 1, fieldLabel: 'Upload Resume/CV' });
|
|
358
|
+
// Wait for parsing banner to disappear
|
|
359
|
+
const waitHandler = getToolHandler('geometra_wait_for_resume_parse');
|
|
360
|
+
const waitResult = await waitHandler({ text: 'Parsing', timeoutMs: 30000 });
|
|
361
|
+
expect(waitResult.content[0].text).toContain('condition satisfied');
|
|
362
|
+
// After parsing, fields are pre-filled from resume
|
|
363
|
+
bumpMockUiRevision();
|
|
364
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
365
|
+
meta: { pageUrl: 'https://jobs.lever.co/acme/apply', scrollX: 0, scrollY: 0 },
|
|
366
|
+
children: [
|
|
367
|
+
node('button', 'Upload Resume/CV', { path: [0], value: 'resume.pdf' }),
|
|
368
|
+
node('textbox', 'Full name', { value: 'Taylor Smith', path: [2], state: { required: true } }),
|
|
369
|
+
node('textbox', 'Email', { value: 'taylor@example.com', path: [3], state: { required: true } }),
|
|
370
|
+
node('textbox', 'Phone', { value: '(555) 987-6543', path: [4] }),
|
|
371
|
+
],
|
|
372
|
+
});
|
|
373
|
+
// Now fill with skipPreFilled to avoid overwriting parsed data
|
|
374
|
+
mockState.formSchemas = [{
|
|
375
|
+
formId: 'fm:0',
|
|
376
|
+
name: 'Application',
|
|
377
|
+
fieldCount: 4,
|
|
378
|
+
requiredCount: 2,
|
|
379
|
+
invalidCount: 0,
|
|
380
|
+
fields: [
|
|
381
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true, value: 'Taylor Smith' },
|
|
382
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Email', required: true, value: 'taylor@example.com' },
|
|
383
|
+
{ id: 'ff:0.2', kind: 'text', label: 'Phone', value: '(555) 987-6543' },
|
|
384
|
+
{ id: 'ff:0.3', kind: 'text', label: 'LinkedIn URL' },
|
|
385
|
+
],
|
|
386
|
+
}];
|
|
387
|
+
const fillHandler = getToolHandler('geometra_fill_form');
|
|
388
|
+
const fillResult = await fillHandler({
|
|
389
|
+
formId: 'fm:0',
|
|
390
|
+
valuesById: {
|
|
391
|
+
'ff:0.0': 'Taylor Smith',
|
|
392
|
+
'ff:0.1': 'taylor@example.com',
|
|
393
|
+
'ff:0.2': '(555) 987-6543',
|
|
394
|
+
'ff:0.3': 'https://linkedin.com/in/taylorsmith',
|
|
395
|
+
},
|
|
396
|
+
skipPreFilled: true,
|
|
397
|
+
stopOnError: true,
|
|
398
|
+
failOnInvalid: false,
|
|
399
|
+
includeSteps: true,
|
|
400
|
+
detail: 'minimal',
|
|
401
|
+
});
|
|
402
|
+
const payload = JSON.parse(fillResult.content[0].text);
|
|
403
|
+
expect(payload).toMatchObject({ completed: true });
|
|
404
|
+
// Three fields already matched their intended values
|
|
405
|
+
expect(payload).toHaveProperty('skippedPreFilled', 3);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
// ── 4. Ashby-style ───────────────────────────────────────────────
|
|
409
|
+
describe('Ashby-style custom controls', () => {
|
|
410
|
+
beforeEach(() => {
|
|
411
|
+
mockState.formSchemas = [{
|
|
412
|
+
formId: 'fm:0',
|
|
413
|
+
name: 'Application Form',
|
|
414
|
+
fieldCount: 4,
|
|
415
|
+
requiredCount: 2,
|
|
416
|
+
invalidCount: 0,
|
|
417
|
+
fields: [
|
|
418
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full Name', required: true },
|
|
419
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
|
|
420
|
+
{ id: 'ff:0.2', kind: 'toggle', label: 'I agree to the Privacy Policy', controlType: 'checkbox' },
|
|
421
|
+
{ id: 'ff:0.3', kind: 'choice', label: 'How did you hear about us?', choiceType: 'combobox', optionCount: 8 },
|
|
422
|
+
],
|
|
423
|
+
}];
|
|
424
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
425
|
+
meta: { pageUrl: 'https://jobs.ashbyhq.com/acme/apply', scrollX: 0, scrollY: 0 },
|
|
426
|
+
children: [
|
|
427
|
+
node('textbox', 'Full Name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
|
|
428
|
+
node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
|
|
429
|
+
node('checkbox', 'I agree to the Privacy Policy', { path: [2], state: { checked: false } }),
|
|
430
|
+
node('combobox', 'How did you hear about us?', { value: '', path: [3] }),
|
|
431
|
+
],
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
it('uses set_checked for visually hidden custom checkboxes', async () => {
|
|
435
|
+
const handler = getToolHandler('geometra_set_checked');
|
|
436
|
+
const result = await handler({
|
|
437
|
+
label: 'I agree to the Privacy Policy',
|
|
438
|
+
checked: true,
|
|
439
|
+
controlType: 'checkbox',
|
|
440
|
+
detail: 'terse',
|
|
441
|
+
});
|
|
442
|
+
const payload = JSON.parse(result.content[0].text);
|
|
443
|
+
expect(payload).toMatchObject({ label: 'I agree to the Privacy Policy', checked: true, controlType: 'checkbox' });
|
|
444
|
+
expect(mockState.sendSetChecked).toHaveBeenCalledWith(mockState.session, 'I agree to the Privacy Policy', { checked: true, exact: undefined, controlType: 'checkbox' }, undefined);
|
|
445
|
+
});
|
|
446
|
+
it('uses pick_listbox_option for combobox dropdowns', async () => {
|
|
447
|
+
const handler = getToolHandler('geometra_pick_listbox_option');
|
|
448
|
+
const result = await handler({
|
|
449
|
+
fieldLabel: 'How did you hear about us?',
|
|
450
|
+
label: 'LinkedIn',
|
|
451
|
+
detail: 'terse',
|
|
452
|
+
});
|
|
453
|
+
const payload = JSON.parse(result.content[0].text);
|
|
454
|
+
expect(payload).toMatchObject({ label: 'LinkedIn', fieldLabel: 'How did you hear about us?' });
|
|
455
|
+
expect(mockState.sendListboxPick).toHaveBeenCalledWith(mockState.session, 'LinkedIn', expect.objectContaining({
|
|
456
|
+
fieldLabel: 'How did you hear about us?',
|
|
457
|
+
}), undefined);
|
|
458
|
+
});
|
|
459
|
+
it('fills text fields and toggles together via fill_fields', async () => {
|
|
460
|
+
const handler = getToolHandler('geometra_fill_fields');
|
|
461
|
+
const result = await handler({
|
|
462
|
+
fields: [
|
|
463
|
+
{ kind: 'text', fieldId: 'ff:0.0', value: 'Taylor Smith' },
|
|
464
|
+
{ kind: 'text', fieldId: 'ff:0.1', value: 'taylor@example.com' },
|
|
465
|
+
{ kind: 'toggle', fieldId: 'ff:0.2', checked: true },
|
|
466
|
+
],
|
|
467
|
+
stopOnError: true,
|
|
468
|
+
failOnInvalid: false,
|
|
469
|
+
includeSteps: true,
|
|
470
|
+
detail: 'minimal',
|
|
471
|
+
});
|
|
472
|
+
const payload = JSON.parse(result.content[0].text);
|
|
473
|
+
expect(payload).toMatchObject({
|
|
474
|
+
completed: true,
|
|
475
|
+
fieldCount: 3,
|
|
476
|
+
successCount: 3,
|
|
477
|
+
errorCount: 0,
|
|
478
|
+
});
|
|
479
|
+
expect(mockState.sendFieldText).toHaveBeenCalledTimes(2);
|
|
480
|
+
expect(mockState.sendSetChecked).toHaveBeenCalledTimes(1);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
// ── 5. Multi-page flow ───────────────────────────────────────────
|
|
484
|
+
describe('multi-page application flow', () => {
|
|
485
|
+
it('tracks workflow state across page navigations', async () => {
|
|
486
|
+
// Page 1: Personal info
|
|
487
|
+
mockState.formSchemas = [{
|
|
488
|
+
formId: 'fm:0',
|
|
489
|
+
name: 'Personal Info',
|
|
490
|
+
fieldCount: 2,
|
|
491
|
+
requiredCount: 2,
|
|
492
|
+
invalidCount: 0,
|
|
493
|
+
fields: [
|
|
494
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
495
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
|
|
496
|
+
],
|
|
497
|
+
}];
|
|
498
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
499
|
+
meta: { pageUrl: 'https://careers.acme.com/apply/step1', scrollX: 0, scrollY: 0 },
|
|
500
|
+
children: [
|
|
501
|
+
node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
|
|
502
|
+
node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
|
|
503
|
+
node('button', 'Next', { path: [2] }),
|
|
504
|
+
],
|
|
505
|
+
});
|
|
506
|
+
// Fill page 1
|
|
507
|
+
const fillHandler = getToolHandler('geometra_fill_form');
|
|
508
|
+
const fillResult1 = await fillHandler({
|
|
509
|
+
formId: 'fm:0',
|
|
510
|
+
valuesById: {
|
|
511
|
+
'ff:0.0': 'Taylor Smith',
|
|
512
|
+
'ff:0.1': 'taylor@example.com',
|
|
513
|
+
},
|
|
514
|
+
stopOnError: true,
|
|
515
|
+
failOnInvalid: false,
|
|
516
|
+
includeSteps: false,
|
|
517
|
+
detail: 'terse',
|
|
518
|
+
});
|
|
519
|
+
const payload1 = JSON.parse(fillResult1.content[0].text);
|
|
520
|
+
expect(payload1).toMatchObject({ completed: true });
|
|
521
|
+
// Click Next -> page changes
|
|
522
|
+
bumpMockUiRevision();
|
|
523
|
+
mockState.formSchemas = [{
|
|
524
|
+
formId: 'fm:1',
|
|
525
|
+
name: 'Experience',
|
|
526
|
+
fieldCount: 2,
|
|
527
|
+
requiredCount: 1,
|
|
528
|
+
invalidCount: 0,
|
|
529
|
+
fields: [
|
|
530
|
+
{ id: 'ff:1.0', kind: 'text', label: 'Current Company', required: true },
|
|
531
|
+
{ id: 'ff:1.1', kind: 'text', label: 'Years of Experience' },
|
|
532
|
+
],
|
|
533
|
+
}];
|
|
534
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
535
|
+
meta: { pageUrl: 'https://careers.acme.com/apply/step2', scrollX: 0, scrollY: 0 },
|
|
536
|
+
children: [
|
|
537
|
+
node('textbox', 'Current Company', { value: 'Acme Corp', path: [0], state: { required: true } }),
|
|
538
|
+
node('textbox', 'Years of Experience', { value: '5', path: [1] }),
|
|
539
|
+
node('button', 'Submit', { path: [2] }),
|
|
540
|
+
],
|
|
541
|
+
});
|
|
542
|
+
// Fill page 2
|
|
543
|
+
const fillResult2 = await fillHandler({
|
|
544
|
+
formId: 'fm:1',
|
|
545
|
+
valuesById: {
|
|
546
|
+
'ff:1.0': 'Acme Corp',
|
|
547
|
+
'ff:1.1': '5',
|
|
548
|
+
},
|
|
549
|
+
stopOnError: true,
|
|
550
|
+
failOnInvalid: false,
|
|
551
|
+
includeSteps: false,
|
|
552
|
+
detail: 'terse',
|
|
553
|
+
});
|
|
554
|
+
const payload2 = JSON.parse(fillResult2.content[0].text);
|
|
555
|
+
expect(payload2).toMatchObject({ completed: true });
|
|
556
|
+
// Check workflow state tracks both pages
|
|
557
|
+
const stateHandler = getToolHandler('geometra_workflow_state');
|
|
558
|
+
const stateResult = await stateHandler({});
|
|
559
|
+
const statePayload = JSON.parse(stateResult.content[0].text);
|
|
560
|
+
expect(statePayload.pageCount).toBe(2);
|
|
561
|
+
expect(statePayload.totalFieldsFilled).toBeGreaterThanOrEqual(4);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
// ── 6. CAPTCHA detection ─────────────────────────────────────────
|
|
565
|
+
describe('CAPTCHA detection', () => {
|
|
566
|
+
it('surfaces captchaDetected when reCAPTCHA iframe is in the tree', async () => {
|
|
567
|
+
// Override buildPageModel to return captcha detection
|
|
568
|
+
const { buildPageModel } = await import('../session.js');
|
|
569
|
+
const mockBuildPageModel = buildPageModel;
|
|
570
|
+
mockBuildPageModel.mockReturnValueOnce({
|
|
571
|
+
viewport: { width: 1280, height: 800 },
|
|
572
|
+
archetypes: ['form'],
|
|
573
|
+
summary: { landmarkCount: 0, formCount: 1, dialogCount: 0, listCount: 0, focusableCount: 4 },
|
|
574
|
+
captcha: { detected: true, type: 'recaptcha', hint: 'Google reCAPTCHA detected' },
|
|
575
|
+
primaryActions: [],
|
|
576
|
+
landmarks: [],
|
|
577
|
+
forms: [],
|
|
578
|
+
dialogs: [],
|
|
579
|
+
lists: [],
|
|
580
|
+
});
|
|
581
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
582
|
+
meta: { pageUrl: 'https://boards.greenhouse.io/apply', scrollX: 0, scrollY: 0 },
|
|
583
|
+
children: [
|
|
584
|
+
node('textbox', 'Email', { value: '', path: [0], state: { required: true } }),
|
|
585
|
+
node('group', 'reCAPTCHA', {
|
|
586
|
+
path: [1],
|
|
587
|
+
meta: { pageUrl: 'https://boards.greenhouse.io/apply' },
|
|
588
|
+
children: [
|
|
589
|
+
node('checkbox', 'I\'m not a robot', { path: [1, 0] }),
|
|
590
|
+
],
|
|
591
|
+
}),
|
|
592
|
+
node('button', 'Submit', { path: [2] }),
|
|
593
|
+
],
|
|
594
|
+
});
|
|
595
|
+
const handler = getToolHandler('geometra_page_model');
|
|
596
|
+
const result = await handler({});
|
|
597
|
+
const payload = JSON.parse(result.content[0].text);
|
|
598
|
+
expect(payload).toMatchObject({
|
|
599
|
+
captcha: {
|
|
600
|
+
detected: true,
|
|
601
|
+
type: 'recaptcha',
|
|
602
|
+
hint: 'Google reCAPTCHA detected',
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
// ── 7. Date/phone normalization ──────────────────────────────────
|
|
608
|
+
describe('date and phone normalization', () => {
|
|
609
|
+
beforeEach(() => {
|
|
610
|
+
mockState.formSchemas = [{
|
|
611
|
+
formId: 'fm:0',
|
|
612
|
+
name: 'Application',
|
|
613
|
+
fieldCount: 3,
|
|
614
|
+
requiredCount: 2,
|
|
615
|
+
invalidCount: 0,
|
|
616
|
+
fields: [
|
|
617
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Start Date', required: true, format: { placeholder: 'MM/DD/YYYY', inputType: 'text' } },
|
|
618
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Phone', required: true, format: { placeholder: '(555) 123-4567', inputType: 'tel' } },
|
|
619
|
+
{ id: 'ff:0.2', kind: 'text', label: 'Full name' },
|
|
620
|
+
],
|
|
621
|
+
}];
|
|
622
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
623
|
+
meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
|
|
624
|
+
children: [
|
|
625
|
+
node('textbox', 'Start Date', {
|
|
626
|
+
value: '03/15/2025',
|
|
627
|
+
path: [0],
|
|
628
|
+
state: { required: true },
|
|
629
|
+
meta: { placeholder: 'MM/DD/YYYY' },
|
|
630
|
+
}),
|
|
631
|
+
node('textbox', 'Phone', {
|
|
632
|
+
value: '(555) 987-6543',
|
|
633
|
+
path: [1],
|
|
634
|
+
state: { required: true },
|
|
635
|
+
meta: { placeholder: '(555) 123-4567' },
|
|
636
|
+
}),
|
|
637
|
+
node('textbox', 'Full name', { value: 'Taylor Smith', path: [2] }),
|
|
638
|
+
],
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
it('sends date and phone values as-is through fill_form (server normalizes)', async () => {
|
|
642
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
643
|
+
const result = await handler({
|
|
644
|
+
formId: 'fm:0',
|
|
645
|
+
valuesById: {
|
|
646
|
+
'ff:0.0': '03/15/2025',
|
|
647
|
+
'ff:0.1': '(555) 987-6543',
|
|
648
|
+
'ff:0.2': 'Taylor Smith',
|
|
649
|
+
},
|
|
650
|
+
stopOnError: true,
|
|
651
|
+
failOnInvalid: false,
|
|
652
|
+
includeSteps: true,
|
|
653
|
+
detail: 'minimal',
|
|
654
|
+
});
|
|
655
|
+
const payload = JSON.parse(result.content[0].text);
|
|
656
|
+
expect(payload).toMatchObject({
|
|
657
|
+
completed: true,
|
|
658
|
+
fieldCount: 3,
|
|
659
|
+
successCount: 3,
|
|
660
|
+
errorCount: 0,
|
|
661
|
+
});
|
|
662
|
+
// The text handler should have been called with the formatted values
|
|
663
|
+
expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Start Date', '03/15/2025', expect.objectContaining({ fieldId: 'ff:0.0' }), undefined);
|
|
664
|
+
expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Phone', '(555) 987-6543', expect.objectContaining({ fieldId: 'ff:0.1' }), undefined);
|
|
665
|
+
});
|
|
666
|
+
it('exposes format hints in the form schema for agent consumption', async () => {
|
|
667
|
+
const handler = getToolHandler('geometra_form_schema');
|
|
668
|
+
const result = await handler({});
|
|
669
|
+
const payload = JSON.parse(result.content[0].text);
|
|
670
|
+
expect(payload).toHaveProperty('forms');
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
// ── 8. Error recovery ────────────────────────────────────────────
|
|
674
|
+
describe('error recovery with suggestion', () => {
|
|
675
|
+
it('populates suggestion when a choice field fill fails', async () => {
|
|
676
|
+
mockState.formSchemas = [{
|
|
677
|
+
formId: 'fm:0',
|
|
678
|
+
name: 'Application',
|
|
679
|
+
fieldCount: 2,
|
|
680
|
+
requiredCount: 1,
|
|
681
|
+
invalidCount: 0,
|
|
682
|
+
fields: [
|
|
683
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
684
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Department', choiceType: 'select', optionCount: 5 },
|
|
685
|
+
],
|
|
686
|
+
}];
|
|
687
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
688
|
+
meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
|
|
689
|
+
children: [
|
|
690
|
+
node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
|
|
691
|
+
node('combobox', 'Department', { value: '', path: [1] }),
|
|
692
|
+
],
|
|
693
|
+
});
|
|
694
|
+
// Make the choice fill throw a "no option found" error
|
|
695
|
+
mockState.sendFieldChoice.mockRejectedValueOnce(new Error('No option found matching "Engineering"'));
|
|
696
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
697
|
+
const result = await handler({
|
|
698
|
+
formId: 'fm:0',
|
|
699
|
+
valuesById: {
|
|
700
|
+
'ff:0.0': 'Taylor Smith',
|
|
701
|
+
'ff:0.1': 'Engineering',
|
|
702
|
+
},
|
|
703
|
+
stopOnError: true,
|
|
704
|
+
failOnInvalid: false,
|
|
705
|
+
includeSteps: true,
|
|
706
|
+
detail: 'minimal',
|
|
707
|
+
});
|
|
708
|
+
const payload = JSON.parse(result.content[0].text);
|
|
709
|
+
const steps = payload.steps;
|
|
710
|
+
expect(payload.errorCount).toBeGreaterThan(0);
|
|
711
|
+
// Find the failed step with suggestion
|
|
712
|
+
const failedStep = steps.find(s => !s.ok);
|
|
713
|
+
expect(failedStep).toBeDefined();
|
|
714
|
+
expect(failedStep.error).toContain('No option found');
|
|
715
|
+
expect(failedStep.suggestion).toContain('geometra_pick_listbox_option');
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
// ── 9. Confidence scoring ────────────────────────────────────────
|
|
719
|
+
describe('confidence scoring', () => {
|
|
720
|
+
it('returns minConfidence 1.0 for all valuesById entries', async () => {
|
|
721
|
+
mockState.formSchemas = [{
|
|
722
|
+
formId: 'fm:0',
|
|
723
|
+
name: 'Application',
|
|
724
|
+
fieldCount: 3,
|
|
725
|
+
requiredCount: 3,
|
|
726
|
+
invalidCount: 0,
|
|
727
|
+
fields: [
|
|
728
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
729
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
|
|
730
|
+
{ id: 'ff:0.2', kind: 'text', label: 'Phone', required: true },
|
|
731
|
+
],
|
|
732
|
+
}];
|
|
733
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
734
|
+
meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
|
|
735
|
+
children: [
|
|
736
|
+
node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
|
|
737
|
+
node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
|
|
738
|
+
node('textbox', 'Phone', { value: '5559876543', path: [2], state: { required: true } }),
|
|
739
|
+
],
|
|
740
|
+
});
|
|
741
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
742
|
+
const result = await handler({
|
|
743
|
+
formId: 'fm:0',
|
|
744
|
+
valuesById: {
|
|
745
|
+
'ff:0.0': 'Taylor Smith',
|
|
746
|
+
'ff:0.1': 'taylor@example.com',
|
|
747
|
+
'ff:0.2': '5559876543',
|
|
748
|
+
},
|
|
749
|
+
stopOnError: true,
|
|
750
|
+
failOnInvalid: false,
|
|
751
|
+
includeSteps: true,
|
|
752
|
+
detail: 'minimal',
|
|
753
|
+
});
|
|
754
|
+
const payload = JSON.parse(result.content[0].text);
|
|
755
|
+
expect(payload.minConfidence).toBe(1.0);
|
|
756
|
+
const steps = payload.steps;
|
|
757
|
+
for (const step of steps) {
|
|
758
|
+
expect(step.confidence).toBe(1.0);
|
|
759
|
+
expect(step.matchMethod).toBe('id');
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
it('returns lower confidence for valuesByLabel with exact match', async () => {
|
|
763
|
+
mockState.formSchemas = [{
|
|
764
|
+
formId: 'fm:0',
|
|
765
|
+
name: 'Application',
|
|
766
|
+
fieldCount: 2,
|
|
767
|
+
requiredCount: 2,
|
|
768
|
+
invalidCount: 0,
|
|
769
|
+
fields: [
|
|
770
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
771
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
|
|
772
|
+
],
|
|
773
|
+
}];
|
|
774
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
775
|
+
meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
|
|
776
|
+
children: [
|
|
777
|
+
node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
|
|
778
|
+
node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
|
|
779
|
+
],
|
|
780
|
+
});
|
|
781
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
782
|
+
const result = await handler({
|
|
783
|
+
formId: 'fm:0',
|
|
784
|
+
valuesByLabel: {
|
|
785
|
+
'Full name': 'Taylor Smith',
|
|
786
|
+
'Email': 'taylor@example.com',
|
|
787
|
+
},
|
|
788
|
+
stopOnError: true,
|
|
789
|
+
failOnInvalid: false,
|
|
790
|
+
includeSteps: true,
|
|
791
|
+
detail: 'minimal',
|
|
792
|
+
});
|
|
793
|
+
const payload = JSON.parse(result.content[0].text);
|
|
794
|
+
expect(payload.minConfidence).toBe(0.95);
|
|
795
|
+
const steps = payload.steps;
|
|
796
|
+
for (const step of steps) {
|
|
797
|
+
expect(step.confidence).toBe(0.95);
|
|
798
|
+
expect(step.matchMethod).toBe('label-exact');
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
it('returns 0.8 confidence for normalized label matches', async () => {
|
|
802
|
+
mockState.formSchemas = [{
|
|
803
|
+
formId: 'fm:0',
|
|
804
|
+
name: 'Application',
|
|
805
|
+
fieldCount: 2,
|
|
806
|
+
requiredCount: 2,
|
|
807
|
+
invalidCount: 0,
|
|
808
|
+
fields: [
|
|
809
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
810
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Email Address', required: true },
|
|
811
|
+
],
|
|
812
|
+
}];
|
|
813
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
814
|
+
meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
|
|
815
|
+
children: [
|
|
816
|
+
node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
|
|
817
|
+
node('textbox', 'Email Address', { value: 'taylor@example.com', path: [1], state: { required: true } }),
|
|
818
|
+
],
|
|
819
|
+
});
|
|
820
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
821
|
+
const result = await handler({
|
|
822
|
+
formId: 'fm:0',
|
|
823
|
+
valuesByLabel: {
|
|
824
|
+
'full name': 'Taylor Smith',
|
|
825
|
+
'email address': 'taylor@example.com',
|
|
826
|
+
},
|
|
827
|
+
stopOnError: true,
|
|
828
|
+
failOnInvalid: false,
|
|
829
|
+
includeSteps: true,
|
|
830
|
+
detail: 'minimal',
|
|
831
|
+
});
|
|
832
|
+
const payload = JSON.parse(result.content[0].text);
|
|
833
|
+
// Normalized labels should give lower confidence
|
|
834
|
+
expect(payload.minConfidence).toBeLessThanOrEqual(0.95);
|
|
835
|
+
});
|
|
836
|
+
it('mixes valuesById (1.0) and valuesByLabel (0.95) and reports minimum', async () => {
|
|
837
|
+
mockState.formSchemas = [{
|
|
838
|
+
formId: 'fm:0',
|
|
839
|
+
name: 'Application',
|
|
840
|
+
fieldCount: 3,
|
|
841
|
+
requiredCount: 3,
|
|
842
|
+
invalidCount: 0,
|
|
843
|
+
fields: [
|
|
844
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
845
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Email', required: true },
|
|
846
|
+
{ id: 'ff:0.2', kind: 'text', label: 'Phone', required: true },
|
|
847
|
+
],
|
|
848
|
+
}];
|
|
849
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
850
|
+
meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 0 },
|
|
851
|
+
children: [
|
|
852
|
+
node('textbox', 'Full name', { value: 'Taylor Smith', path: [0], state: { required: true } }),
|
|
853
|
+
node('textbox', 'Email', { value: 'taylor@example.com', path: [1], state: { required: true } }),
|
|
854
|
+
node('textbox', 'Phone', { value: '5559876543', path: [2], state: { required: true } }),
|
|
855
|
+
],
|
|
856
|
+
});
|
|
857
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
858
|
+
const result = await handler({
|
|
859
|
+
formId: 'fm:0',
|
|
860
|
+
valuesById: {
|
|
861
|
+
'ff:0.0': 'Taylor Smith',
|
|
862
|
+
},
|
|
863
|
+
valuesByLabel: {
|
|
864
|
+
'Email': 'taylor@example.com',
|
|
865
|
+
'Phone': '5559876543',
|
|
866
|
+
},
|
|
867
|
+
stopOnError: true,
|
|
868
|
+
failOnInvalid: false,
|
|
869
|
+
includeSteps: true,
|
|
870
|
+
detail: 'minimal',
|
|
871
|
+
});
|
|
872
|
+
const payload = JSON.parse(result.content[0].text);
|
|
873
|
+
// minConfidence should be 0.95 (from the label-exact matches), not 1.0
|
|
874
|
+
expect(payload.minConfidence).toBe(0.95);
|
|
875
|
+
const steps = payload.steps;
|
|
876
|
+
const idStep = steps.find(s => s.matchMethod === 'id');
|
|
877
|
+
const labelSteps = steps.filter(s => s.matchMethod === 'label-exact');
|
|
878
|
+
expect(idStep).toBeDefined();
|
|
879
|
+
expect(idStep.confidence).toBe(1.0);
|
|
880
|
+
expect(labelSteps.length).toBe(2);
|
|
881
|
+
for (const ls of labelSteps) {
|
|
882
|
+
expect(ls.confidence).toBe(0.95);
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
});
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks';
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
6
|
-
import { connect, connectThroughProxy, disconnect, getSession, prewarmProxy, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
6
|
+
import { connect, connectThroughProxy, disconnect, getSession, prewarmProxy, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
7
7
|
function checkedStateInput() {
|
|
8
8
|
return z
|
|
9
9
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -1874,6 +1874,68 @@ Use this after navigating to a new page in a multi-step flow (e.g. job applicati
|
|
|
1874
1874
|
}));
|
|
1875
1875
|
});
|
|
1876
1876
|
// ── disconnect ───────────────────────────────────────────────
|
|
1877
|
+
server.tool('geometra_generate_pdf', `Generate a PDF from the current page or from provided HTML content. Returns the PDF as base64-encoded data.
|
|
1878
|
+
|
|
1879
|
+
**Two modes:**
|
|
1880
|
+
- **Current page:** Omit \`html\` to PDF-print whatever the proxy browser is currently showing (useful after navigating to a page and filling forms).
|
|
1881
|
+
- **HTML content:** Pass an \`html\` string to render and convert to PDF (useful for generating CVs, reports, or any custom document from a template).
|
|
1882
|
+
|
|
1883
|
+
Returns \`{ pdf, pageUrl }\` where \`pdf\` is the base64-encoded PDF bytes.`, {
|
|
1884
|
+
html: z
|
|
1885
|
+
.string()
|
|
1886
|
+
.optional()
|
|
1887
|
+
.describe('Full HTML string to render as PDF. If omitted, the current page is used.'),
|
|
1888
|
+
format: z
|
|
1889
|
+
.enum(['A4', 'Letter'])
|
|
1890
|
+
.optional()
|
|
1891
|
+
.default('A4')
|
|
1892
|
+
.describe('Paper format.'),
|
|
1893
|
+
landscape: z
|
|
1894
|
+
.boolean()
|
|
1895
|
+
.optional()
|
|
1896
|
+
.default(false)
|
|
1897
|
+
.describe('Print in landscape orientation.'),
|
|
1898
|
+
margin: z
|
|
1899
|
+
.string()
|
|
1900
|
+
.optional()
|
|
1901
|
+
.default('1cm')
|
|
1902
|
+
.describe('CSS margin applied to all sides (e.g. "1cm", "0.5in", "10mm").'),
|
|
1903
|
+
printBackground: z
|
|
1904
|
+
.boolean()
|
|
1905
|
+
.optional()
|
|
1906
|
+
.default(true)
|
|
1907
|
+
.describe('Include background graphics and colors.'),
|
|
1908
|
+
}, async ({ html, format, landscape, margin, printBackground }) => {
|
|
1909
|
+
const session = getSession();
|
|
1910
|
+
if (!session)
|
|
1911
|
+
return err('Not connected. Call geometra_connect first.');
|
|
1912
|
+
try {
|
|
1913
|
+
const wait = await sendPdfGenerate(session, {
|
|
1914
|
+
html: html ?? undefined,
|
|
1915
|
+
format,
|
|
1916
|
+
landscape,
|
|
1917
|
+
margin,
|
|
1918
|
+
printBackground,
|
|
1919
|
+
});
|
|
1920
|
+
const result = wait.result;
|
|
1921
|
+
const pdfBase64 = typeof result?.pdf === 'string' ? result.pdf : undefined;
|
|
1922
|
+
if (!pdfBase64)
|
|
1923
|
+
return err('PDF generation failed — no data returned from proxy.');
|
|
1924
|
+
const pageUrl = typeof result?.pageUrl === 'string' ? result.pageUrl : undefined;
|
|
1925
|
+
const sizeKb = Math.round((pdfBase64.length * 3) / 4 / 1024);
|
|
1926
|
+
return ok(JSON.stringify({
|
|
1927
|
+
pdf: pdfBase64,
|
|
1928
|
+
pageUrl,
|
|
1929
|
+
format,
|
|
1930
|
+
landscape,
|
|
1931
|
+
sizeKb,
|
|
1932
|
+
...(html ? { source: 'html' } : { source: 'current-page' }),
|
|
1933
|
+
}));
|
|
1934
|
+
}
|
|
1935
|
+
catch (e) {
|
|
1936
|
+
return err(`PDF generation failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1877
1939
|
server.tool('geometra_disconnect', `Disconnect from the Geometra server. Proxy-backed sessions keep compatible browsers alive by default so the next geometra_connect can reuse them quickly; pass closeBrowser=true to fully tear down the warm proxy/browser pool.`, {
|
|
1878
1940
|
closeBrowser: z.boolean().optional().default(false).describe('Fully close the spawned proxy/browser instead of keeping it warm for reuse'),
|
|
1879
1941
|
}, async ({ closeBrowser }) => {
|
package/dist/session.d.ts
CHANGED
|
@@ -569,6 +569,14 @@ export declare function sendWheel(session: Session, deltaY: number, opts?: {
|
|
|
569
569
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
570
570
|
/** Capture a viewport screenshot from the proxy (base64 PNG). */
|
|
571
571
|
export declare function sendScreenshot(session: Session, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
572
|
+
/** Generate a PDF from the current page or from provided HTML. Returns base64 PDF data. */
|
|
573
|
+
export declare function sendPdfGenerate(session: Session, options?: {
|
|
574
|
+
html?: string;
|
|
575
|
+
format?: 'A4' | 'Letter';
|
|
576
|
+
landscape?: boolean;
|
|
577
|
+
margin?: string;
|
|
578
|
+
printBackground?: boolean;
|
|
579
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
572
580
|
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
573
581
|
export declare function sendNavigate(session: Session, url: string, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
574
582
|
/**
|
package/dist/session.js
CHANGED
|
@@ -879,6 +879,17 @@ export function sendWheel(session, deltaY, opts, timeoutMs) {
|
|
|
879
879
|
export function sendScreenshot(session, timeoutMs = 10_000) {
|
|
880
880
|
return sendAndWaitForUpdate(session, { type: 'screenshot' }, timeoutMs);
|
|
881
881
|
}
|
|
882
|
+
/** Generate a PDF from the current page or from provided HTML. Returns base64 PDF data. */
|
|
883
|
+
export function sendPdfGenerate(session, options, timeoutMs = 30_000) {
|
|
884
|
+
return sendAndWaitForUpdate(session, {
|
|
885
|
+
type: 'pdfGenerate',
|
|
886
|
+
...(options?.html ? { html: options.html } : {}),
|
|
887
|
+
...(options?.format ? { format: options.format } : {}),
|
|
888
|
+
...(options?.landscape !== undefined ? { landscape: options.landscape } : {}),
|
|
889
|
+
...(options?.margin ? { margin: options.margin } : {}),
|
|
890
|
+
...(options?.printBackground !== undefined ? { printBackground: options.printBackground } : {}),
|
|
891
|
+
}, timeoutMs);
|
|
892
|
+
}
|
|
882
893
|
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
883
894
|
export function sendNavigate(session, url, timeoutMs = 15_000) {
|
|
884
895
|
return sendAndWaitForUpdate(session, {
|
package/package.json
CHANGED