@geometra/mcp 1.19.15 → 1.19.17
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.
- package/README.md +7 -6
- package/dist/__tests__/proxy-session-actions.test.js +63 -5
- package/dist/__tests__/server-batch-results.test.js +199 -16
- package/dist/__tests__/session-model.test.js +12 -3
- package/dist/proxy-spawn.d.ts +11 -0
- package/dist/proxy-spawn.js +46 -19
- package/dist/server.js +408 -35
- package/dist/session.d.ts +44 -7
- package/dist/session.js +311 -39
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -21,8 +21,8 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
|
|
|
21
21
|
| `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; `url: "https://…"` is auto-coerced onto the proxy path |
|
|
22
22
|
| `geometra_query` | Find elements by stable id, role, name, text content, ancestor/prompt context, current value, or semantic state such as `invalid`, `required`, or `busy` |
|
|
23
23
|
| `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.) |
|
|
24
|
-
| `geometra_form_schema` | Compact, fill-oriented form schema with stable field ids and collapsed radio/button groups |
|
|
25
|
-
| `geometra_fill_form` | Fill a form from `valuesById` / `valuesByLabel` in one MCP call;
|
|
24
|
+
| `geometra_form_schema` | Compact, fill-oriented form schema with stable field ids and collapsed radio/button groups; can auto-connect from `pageUrl` / `url` |
|
|
25
|
+
| `geometra_fill_form` | Fill a form from `valuesById` / `valuesByLabel` in one MCP call; can auto-connect from `pageUrl` / `url` for the lowest-token known-form path |
|
|
26
26
|
| `geometra_fill_fields` | Fill labeled text/choice/toggle/file fields in one MCP call; can return final-only status for the smallest responses |
|
|
27
27
|
| `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip and get one consolidated result, with optional final-only output |
|
|
28
28
|
| `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
|
|
@@ -297,8 +297,8 @@ Agent: geometra_query({ role: "button", name: "Save" })
|
|
|
297
297
|
2. It receives the computed layout (`{ x, y, width, height }` for every node) and the UI tree (`kind`, `semantic`, `props`, `handlers`, `children`).
|
|
298
298
|
3. It builds an accessibility tree from that data — roles, names, focusable state, bounds.
|
|
299
299
|
4. **`geometra_snapshot`** defaults to a **compact** flat list of viewport-visible actionable nodes (minified JSON) to reduce LLM tokens; use `view: "full"` for the complete nested tree.
|
|
300
|
-
5. **`geometra_form_schema`** is the compact form-specific path: stable field ids, required/invalid state, current values, and collapsed choice groups without layout-heavy section detail.
|
|
301
|
-
6. **`geometra_fill_form`** turns a compact values object into semantic field operations server-side, so the model does not need to emit one tool call per field.
|
|
300
|
+
5. **`geometra_form_schema`** is the compact form-specific path: stable field ids, required/invalid state, current values, and collapsed choice groups without layout-heavy section detail. It can auto-connect when you pass `pageUrl` / `url`.
|
|
301
|
+
6. **`geometra_fill_form`** turns a compact values object into semantic field operations server-side, so the model does not need to emit one tool call per field. It can also auto-connect, which collapses “open page + fill known values” into a single tool call.
|
|
302
302
|
7. **`geometra_page_model`** is still the right summary-first path for non-form exploration: page archetypes, stable section ids, counts, top-level landmarks/forms/dialogs/lists, and a few primary actions.
|
|
303
303
|
8. **`geometra_expand_section`** fetches richer details only for the section you care about (fields, actions, headings, nested lists, list items, text preview).
|
|
304
304
|
9. After interactions, action tools return a **semantic delta** when possible (dialogs opened/closed, forms appeared/removed, list counts changed, named/focusable nodes added/removed/updated). If nothing meaningful changed, they fall back to a short current-UI overview.
|
|
@@ -308,8 +308,9 @@ Agent: geometra_query({ role: "button", name: "Save" })
|
|
|
308
308
|
|
|
309
309
|
For long application flows, prefer one of these patterns:
|
|
310
310
|
|
|
311
|
-
1. `
|
|
312
|
-
2. `
|
|
311
|
+
1. `geometra_fill_form({ pageUrl, valuesByLabel })` when you already know the fields you want to set
|
|
312
|
+
2. otherwise `geometra_form_schema`
|
|
313
|
+
3. then `geometra_fill_form`
|
|
313
314
|
3. `geometra_reveal` for far-below-fold targets such as submit buttons
|
|
314
315
|
4. `geometra_run_actions` when you need mixed navigation + waits + field entry
|
|
315
316
|
5. `geometra_page_model` + `geometra_expand_section` when you are still exploring the page rather than filling it
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, describe, expect, it } from 'vitest';
|
|
2
2
|
import { WebSocketServer } from 'ws';
|
|
3
|
-
import { connect, disconnect, sendClick, sendFillFields, sendListboxPick } from '../session.js';
|
|
3
|
+
import { connect, disconnect, sendClick, sendFillFields, sendListboxPick, sendNavigate } from '../session.js';
|
|
4
4
|
describe('proxy-backed MCP actions', () => {
|
|
5
5
|
afterAll(() => {
|
|
6
6
|
disconnect();
|
|
@@ -150,8 +150,8 @@ describe('proxy-backed MCP actions', () => {
|
|
|
150
150
|
try {
|
|
151
151
|
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
152
152
|
await expect(sendFillFields(session, [
|
|
153
|
-
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
154
|
-
{ kind: 'choice', fieldLabel: 'Country', value: 'Germany' },
|
|
153
|
+
{ kind: 'text', fieldId: 'ff:0.0', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
154
|
+
{ kind: 'choice', fieldId: 'ff:0.1', fieldLabel: 'Country', value: 'Germany' },
|
|
155
155
|
], 80)).resolves.toMatchObject({
|
|
156
156
|
status: 'updated',
|
|
157
157
|
timeoutMs: 80,
|
|
@@ -164,8 +164,8 @@ describe('proxy-backed MCP actions', () => {
|
|
|
164
164
|
expect(seenMessage).toMatchObject({
|
|
165
165
|
type: 'fillFields',
|
|
166
166
|
fields: [
|
|
167
|
-
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
168
|
-
{ kind: 'choice', fieldLabel: 'Country', value: 'Germany' },
|
|
167
|
+
{ kind: 'text', fieldId: 'ff:0.0', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
168
|
+
{ kind: 'choice', fieldId: 'ff:0.1', fieldLabel: 'Country', value: 'Germany' },
|
|
169
169
|
],
|
|
170
170
|
});
|
|
171
171
|
}
|
|
@@ -229,4 +229,62 @@ describe('proxy-backed MCP actions', () => {
|
|
|
229
229
|
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
230
230
|
}
|
|
231
231
|
});
|
|
232
|
+
it('supports in-session navigation and waits for the resulting frame', async () => {
|
|
233
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
234
|
+
const received = [];
|
|
235
|
+
wss.on('connection', ws => {
|
|
236
|
+
ws.on('message', raw => {
|
|
237
|
+
const msg = JSON.parse(String(raw));
|
|
238
|
+
received.push(msg);
|
|
239
|
+
if (msg.type === 'resize') {
|
|
240
|
+
ws.send(JSON.stringify({
|
|
241
|
+
type: 'frame',
|
|
242
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
243
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
|
|
244
|
+
}));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (msg.type === 'navigate') {
|
|
248
|
+
ws.send(JSON.stringify({
|
|
249
|
+
type: 'frame',
|
|
250
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
251
|
+
tree: {
|
|
252
|
+
kind: 'box',
|
|
253
|
+
props: {},
|
|
254
|
+
semantic: { tag: 'body', role: 'group' },
|
|
255
|
+
children: [],
|
|
256
|
+
},
|
|
257
|
+
}));
|
|
258
|
+
ws.send(JSON.stringify({
|
|
259
|
+
type: 'ack',
|
|
260
|
+
requestId: msg.requestId,
|
|
261
|
+
result: { pageUrl: msg.url },
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
const port = await new Promise((resolve, reject) => {
|
|
267
|
+
wss.once('listening', () => {
|
|
268
|
+
const address = wss.address();
|
|
269
|
+
if (typeof address === 'object' && address)
|
|
270
|
+
resolve(address.port);
|
|
271
|
+
else
|
|
272
|
+
reject(new Error('Failed to resolve ephemeral WebSocket port'));
|
|
273
|
+
});
|
|
274
|
+
wss.once('error', reject);
|
|
275
|
+
});
|
|
276
|
+
try {
|
|
277
|
+
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
278
|
+
await expect(sendNavigate(session, 'https://jobs.example.com/application', 80)).resolves.toMatchObject({
|
|
279
|
+
status: 'updated',
|
|
280
|
+
timeoutMs: 80,
|
|
281
|
+
result: { pageUrl: 'https://jobs.example.com/application' },
|
|
282
|
+
});
|
|
283
|
+
expect(received.some(message => message.type === 'navigate' && message.url === 'https://jobs.example.com/application')).toBe(true);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
disconnect();
|
|
287
|
+
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
288
|
+
}
|
|
289
|
+
});
|
|
232
290
|
});
|
|
@@ -22,6 +22,9 @@ const mockState = vi.hoisted(() => ({
|
|
|
22
22
|
layout: { x: 0, y: 0, width: 1280, height: 800, children: [] },
|
|
23
23
|
url: 'ws://127.0.0.1:3200',
|
|
24
24
|
updateRevision: 1,
|
|
25
|
+
cachedA11y: undefined,
|
|
26
|
+
cachedA11yRevision: undefined,
|
|
27
|
+
cachedFormSchemas: undefined,
|
|
25
28
|
},
|
|
26
29
|
formSchemas: [],
|
|
27
30
|
connect: vi.fn(),
|
|
@@ -43,6 +46,18 @@ const mockState = vi.hoisted(() => ({
|
|
|
43
46
|
sendWheel: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
44
47
|
waitForUiCondition: vi.fn(async () => true),
|
|
45
48
|
}));
|
|
49
|
+
function resetMockSessionCaches() {
|
|
50
|
+
mockState.session.updateRevision = 1;
|
|
51
|
+
mockState.session.cachedA11y = undefined;
|
|
52
|
+
mockState.session.cachedA11yRevision = undefined;
|
|
53
|
+
mockState.session.cachedFormSchemas = undefined;
|
|
54
|
+
}
|
|
55
|
+
function bumpMockUiRevision() {
|
|
56
|
+
mockState.session.updateRevision += 1;
|
|
57
|
+
mockState.session.cachedA11y = undefined;
|
|
58
|
+
mockState.session.cachedA11yRevision = undefined;
|
|
59
|
+
mockState.session.cachedFormSchemas = undefined;
|
|
60
|
+
}
|
|
46
61
|
vi.mock('../session.js', () => ({
|
|
47
62
|
connect: mockState.connect,
|
|
48
63
|
connectThroughProxy: mockState.connectThroughProxy,
|
|
@@ -89,6 +104,7 @@ function getToolHandler(name) {
|
|
|
89
104
|
describe('batch MCP result shaping', () => {
|
|
90
105
|
beforeEach(() => {
|
|
91
106
|
vi.clearAllMocks();
|
|
107
|
+
resetMockSessionCaches();
|
|
92
108
|
mockState.connect.mockResolvedValue(mockState.session);
|
|
93
109
|
mockState.connectThroughProxy.mockResolvedValue(mockState.session);
|
|
94
110
|
mockState.formSchemas = [];
|
|
@@ -231,6 +247,53 @@ describe('batch MCP result shaping', () => {
|
|
|
231
247
|
});
|
|
232
248
|
expect(payload).not.toHaveProperty('currentUi');
|
|
233
249
|
});
|
|
250
|
+
it('can inline a packed form schema into connect for the low-turn form path', async () => {
|
|
251
|
+
const handler = getToolHandler('geometra_connect');
|
|
252
|
+
mockState.formSchemas = [
|
|
253
|
+
{
|
|
254
|
+
formId: 'fm:0',
|
|
255
|
+
name: 'Application',
|
|
256
|
+
fieldCount: 2,
|
|
257
|
+
requiredCount: 1,
|
|
258
|
+
invalidCount: 0,
|
|
259
|
+
fields: [
|
|
260
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
261
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Work authorization', choiceType: 'group', booleanChoice: true, optionCount: 2 },
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
const result = await handler({
|
|
266
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
267
|
+
headless: true,
|
|
268
|
+
returnForms: true,
|
|
269
|
+
includeContext: 'none',
|
|
270
|
+
schemaFormat: 'packed',
|
|
271
|
+
});
|
|
272
|
+
const payload = JSON.parse(result.content[0].text);
|
|
273
|
+
expect(payload).toMatchObject({
|
|
274
|
+
connected: true,
|
|
275
|
+
transport: 'proxy',
|
|
276
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
277
|
+
formSchema: {
|
|
278
|
+
changed: true,
|
|
279
|
+
formCount: 1,
|
|
280
|
+
format: 'packed',
|
|
281
|
+
schemaId: expect.any(String),
|
|
282
|
+
forms: [
|
|
283
|
+
{
|
|
284
|
+
i: 'fm:0',
|
|
285
|
+
fc: 2,
|
|
286
|
+
rc: 1,
|
|
287
|
+
ic: 0,
|
|
288
|
+
f: [
|
|
289
|
+
{ i: 'ff:0.0', k: 'text', l: 'Full name', r: 1 },
|
|
290
|
+
{ i: 'ff:0.1', k: 'choice', l: 'Work authorization', ch: 'group', b: 1, oc: 2 },
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
});
|
|
234
297
|
it('returns compact form schemas without requiring section expansion', async () => {
|
|
235
298
|
const handler = getToolHandler('geometra_form_schema');
|
|
236
299
|
mockState.formSchemas = [
|
|
@@ -242,22 +305,73 @@ describe('batch MCP result shaping', () => {
|
|
|
242
305
|
invalidCount: 0,
|
|
243
306
|
fields: [
|
|
244
307
|
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
245
|
-
{ id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true },
|
|
246
|
-
{ id: 'ff:0.2', kind: 'choice', label: 'Are you legally authorized to work in Germany?',
|
|
308
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, choiceType: 'select' },
|
|
309
|
+
{ id: 'ff:0.2', kind: 'choice', label: 'Are you legally authorized to work in Germany?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
|
|
247
310
|
{ id: 'ff:0.3', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
|
|
248
311
|
],
|
|
249
312
|
},
|
|
250
313
|
];
|
|
251
314
|
const result = await handler({ maxFields: 20 });
|
|
252
315
|
const payload = JSON.parse(result.content[0].text);
|
|
253
|
-
expect(payload
|
|
254
|
-
|
|
316
|
+
expect(payload).toMatchObject({
|
|
317
|
+
changed: true,
|
|
318
|
+
formCount: 1,
|
|
319
|
+
format: 'compact',
|
|
320
|
+
schemaId: expect.any(String),
|
|
321
|
+
forms: [
|
|
322
|
+
expect.objectContaining({
|
|
323
|
+
formId: 'fm:0',
|
|
324
|
+
fieldCount: 4,
|
|
325
|
+
requiredCount: 3,
|
|
326
|
+
invalidCount: 0,
|
|
327
|
+
}),
|
|
328
|
+
],
|
|
329
|
+
});
|
|
330
|
+
const forms = payload.forms;
|
|
331
|
+
const fields = forms[0]?.fields;
|
|
332
|
+
expect(fields[2]).toMatchObject({
|
|
333
|
+
id: 'ff:0.2',
|
|
334
|
+
kind: 'choice',
|
|
335
|
+
label: 'Are you legally authorized to work in Germany?',
|
|
336
|
+
choiceType: 'group',
|
|
337
|
+
booleanChoice: true,
|
|
338
|
+
optionCount: 2,
|
|
339
|
+
});
|
|
340
|
+
expect(fields[2]).not.toHaveProperty('options');
|
|
341
|
+
});
|
|
342
|
+
it('can auto-connect inside form_schema when given a pageUrl', async () => {
|
|
343
|
+
const handler = getToolHandler('geometra_form_schema');
|
|
344
|
+
mockState.formSchemas = [
|
|
345
|
+
{
|
|
255
346
|
formId: 'fm:0',
|
|
256
|
-
|
|
257
|
-
|
|
347
|
+
name: 'Application',
|
|
348
|
+
fieldCount: 1,
|
|
349
|
+
requiredCount: 1,
|
|
258
350
|
invalidCount: 0,
|
|
259
|
-
|
|
260
|
-
|
|
351
|
+
fields: [{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true }],
|
|
352
|
+
},
|
|
353
|
+
];
|
|
354
|
+
const result = await handler({
|
|
355
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
356
|
+
headless: true,
|
|
357
|
+
});
|
|
358
|
+
const payload = JSON.parse(result.content[0].text);
|
|
359
|
+
expect(mockState.connectThroughProxy).toHaveBeenCalledWith({
|
|
360
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
361
|
+
port: undefined,
|
|
362
|
+
headless: true,
|
|
363
|
+
width: undefined,
|
|
364
|
+
height: undefined,
|
|
365
|
+
slowMo: undefined,
|
|
366
|
+
awaitInitialFrame: undefined,
|
|
367
|
+
});
|
|
368
|
+
expect(payload).toMatchObject({
|
|
369
|
+
autoConnected: true,
|
|
370
|
+
transport: 'proxy',
|
|
371
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
372
|
+
changed: true,
|
|
373
|
+
formCount: 1,
|
|
374
|
+
});
|
|
261
375
|
});
|
|
262
376
|
it('fills a form from ids and labels without echoing long essay content', async () => {
|
|
263
377
|
const longAnswer = 'B'.repeat(220);
|
|
@@ -271,7 +385,7 @@ describe('batch MCP result shaping', () => {
|
|
|
271
385
|
invalidCount: 0,
|
|
272
386
|
fields: [
|
|
273
387
|
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
274
|
-
{ id: 'ff:0.1', kind: 'choice', label: 'Are you legally authorized to work in Germany?',
|
|
388
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Are you legally authorized to work in Germany?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
|
|
275
389
|
{ id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
|
|
276
390
|
{ id: 'ff:0.3', kind: 'text', label: 'Why Geometra?' },
|
|
277
391
|
],
|
|
@@ -304,7 +418,7 @@ describe('batch MCP result shaping', () => {
|
|
|
304
418
|
const payload = JSON.parse(text);
|
|
305
419
|
const steps = payload.steps;
|
|
306
420
|
expect(text).not.toContain(longAnswer);
|
|
307
|
-
expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Are you legally authorized to work in Germany?', 'Yes', { exact: undefined, query: undefined }, undefined);
|
|
421
|
+
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);
|
|
308
422
|
expect(payload).toMatchObject({
|
|
309
423
|
completed: true,
|
|
310
424
|
formId: 'fm:0',
|
|
@@ -342,7 +456,7 @@ describe('batch MCP result shaping', () => {
|
|
|
342
456
|
invalidCount: 0,
|
|
343
457
|
fields: [
|
|
344
458
|
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
345
|
-
{ id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true },
|
|
459
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, choiceType: 'select' },
|
|
346
460
|
{ id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
|
|
347
461
|
],
|
|
348
462
|
},
|
|
@@ -369,10 +483,11 @@ describe('batch MCP result shaping', () => {
|
|
|
369
483
|
});
|
|
370
484
|
const payload = JSON.parse(result.content[0].text);
|
|
371
485
|
expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
|
|
372
|
-
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
373
|
-
{ kind: 'choice', fieldLabel: 'Preferred location', value: 'Berlin, Germany' },
|
|
486
|
+
{ kind: 'text', fieldId: 'ff:0.0', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
487
|
+
{ kind: 'choice', fieldId: 'ff:0.1', fieldLabel: 'Preferred location', value: 'Berlin, Germany', choiceType: 'select' },
|
|
374
488
|
{
|
|
375
489
|
kind: 'toggle',
|
|
490
|
+
fieldId: 'ff:0.2',
|
|
376
491
|
label: 'Share my profile for future roles',
|
|
377
492
|
checked: true,
|
|
378
493
|
controlType: 'checkbox',
|
|
@@ -396,10 +511,77 @@ describe('batch MCP result shaping', () => {
|
|
|
396
511
|
});
|
|
397
512
|
expect(payload).not.toHaveProperty('steps');
|
|
398
513
|
});
|
|
514
|
+
it('can auto-connect inside fill_form for known-label one-turn flows', async () => {
|
|
515
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
516
|
+
mockState.sendFillFields.mockResolvedValueOnce({
|
|
517
|
+
status: 'acknowledged',
|
|
518
|
+
timeoutMs: 6000,
|
|
519
|
+
result: {
|
|
520
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
521
|
+
invalidCount: 0,
|
|
522
|
+
alertCount: 0,
|
|
523
|
+
dialogCount: 0,
|
|
524
|
+
busyCount: 0,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
mockState.formSchemas = [
|
|
528
|
+
{
|
|
529
|
+
formId: 'fm:0',
|
|
530
|
+
name: 'Application',
|
|
531
|
+
fieldCount: 2,
|
|
532
|
+
requiredCount: 2,
|
|
533
|
+
invalidCount: 0,
|
|
534
|
+
fields: [
|
|
535
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
536
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Are you legally authorized to work in Germany?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
|
|
537
|
+
],
|
|
538
|
+
},
|
|
539
|
+
];
|
|
540
|
+
const result = await handler({
|
|
541
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
542
|
+
headless: true,
|
|
543
|
+
valuesByLabel: {
|
|
544
|
+
'Full name': 'Taylor Applicant',
|
|
545
|
+
'Are you legally authorized to work in Germany?': true,
|
|
546
|
+
},
|
|
547
|
+
includeSteps: false,
|
|
548
|
+
detail: 'minimal',
|
|
549
|
+
});
|
|
550
|
+
const payload = JSON.parse(result.content[0].text);
|
|
551
|
+
expect(mockState.connectThroughProxy).toHaveBeenCalledWith({
|
|
552
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
553
|
+
port: undefined,
|
|
554
|
+
headless: true,
|
|
555
|
+
width: undefined,
|
|
556
|
+
height: undefined,
|
|
557
|
+
slowMo: undefined,
|
|
558
|
+
awaitInitialFrame: false,
|
|
559
|
+
});
|
|
560
|
+
expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
|
|
561
|
+
{ kind: 'auto', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
562
|
+
{ kind: 'auto', fieldLabel: 'Are you legally authorized to work in Germany?', value: true },
|
|
563
|
+
]);
|
|
564
|
+
expect(payload).toMatchObject({
|
|
565
|
+
autoConnected: true,
|
|
566
|
+
transport: 'proxy',
|
|
567
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
568
|
+
completed: true,
|
|
569
|
+
execution: 'batched-direct',
|
|
570
|
+
finalSource: 'proxy',
|
|
571
|
+
fieldCount: 2,
|
|
572
|
+
successCount: 2,
|
|
573
|
+
errorCount: 0,
|
|
574
|
+
final: {
|
|
575
|
+
invalidCount: 0,
|
|
576
|
+
alertCount: 0,
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
});
|
|
399
580
|
});
|
|
400
581
|
describe('query and reveal tools', () => {
|
|
401
582
|
beforeEach(() => {
|
|
402
583
|
vi.clearAllMocks();
|
|
584
|
+
resetMockSessionCaches();
|
|
403
585
|
});
|
|
404
586
|
it('lets query disambiguate repeated controls by context text', async () => {
|
|
405
587
|
const handler = getToolHandler('geometra_query');
|
|
@@ -461,7 +643,7 @@ describe('query and reveal tools', () => {
|
|
|
461
643
|
invalidCount: 2,
|
|
462
644
|
fields: [
|
|
463
645
|
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true, invalid: true },
|
|
464
|
-
{ id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, invalid: true },
|
|
646
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, invalid: true, choiceType: 'select' },
|
|
465
647
|
],
|
|
466
648
|
},
|
|
467
649
|
];
|
|
@@ -490,8 +672,8 @@ describe('query and reveal tools', () => {
|
|
|
490
672
|
});
|
|
491
673
|
const payload = JSON.parse(result.content[0].text);
|
|
492
674
|
expect(mockState.sendFillFields).toHaveBeenCalledTimes(1);
|
|
493
|
-
expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Full name', 'Taylor Applicant', { exact: undefined }, undefined);
|
|
494
|
-
expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Preferred location', 'Berlin, Germany', { exact: undefined, query: undefined }, undefined);
|
|
675
|
+
expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Full name', 'Taylor Applicant', { exact: undefined, fieldId: 'ff:0.0' }, undefined);
|
|
676
|
+
expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Preferred location', 'Berlin, Germany', { exact: undefined, query: undefined, choiceType: 'select', fieldId: 'ff:0.1' }, undefined);
|
|
495
677
|
expect(payload).toMatchObject({
|
|
496
678
|
completed: true,
|
|
497
679
|
execution: 'sequential',
|
|
@@ -537,6 +719,7 @@ describe('query and reveal tools', () => {
|
|
|
537
719
|
}),
|
|
538
720
|
],
|
|
539
721
|
});
|
|
722
|
+
bumpMockUiRevision();
|
|
540
723
|
return { status: 'updated', timeoutMs: 2500 };
|
|
541
724
|
});
|
|
542
725
|
const result = await handler({
|
|
@@ -253,6 +253,7 @@ describe('buildFormSchemas', () => {
|
|
|
253
253
|
path: [0, 1],
|
|
254
254
|
state: { required: true },
|
|
255
255
|
value: 'Berlin, Germany',
|
|
256
|
+
meta: { controlTag: 'select' },
|
|
256
257
|
}),
|
|
257
258
|
node('group', undefined, { x: 40, y: 260, width: 520, height: 96 }, {
|
|
258
259
|
path: [0, 2],
|
|
@@ -304,15 +305,17 @@ describe('buildFormSchemas', () => {
|
|
|
304
305
|
expect.objectContaining({
|
|
305
306
|
kind: 'choice',
|
|
306
307
|
label: 'Preferred location',
|
|
308
|
+
choiceType: 'select',
|
|
307
309
|
required: true,
|
|
308
310
|
value: 'Berlin, Germany',
|
|
309
311
|
}),
|
|
310
312
|
expect.objectContaining({
|
|
311
313
|
kind: 'choice',
|
|
312
314
|
label: 'Are you legally authorized to work in Germany?',
|
|
315
|
+
choiceType: 'group',
|
|
313
316
|
required: true,
|
|
314
317
|
optionCount: 2,
|
|
315
|
-
|
|
318
|
+
booleanChoice: true,
|
|
316
319
|
}),
|
|
317
320
|
expect.objectContaining({
|
|
318
321
|
kind: 'toggle',
|
|
@@ -328,8 +331,9 @@ describe('buildFormSchemas', () => {
|
|
|
328
331
|
valueLength: longEssay.length,
|
|
329
332
|
}),
|
|
330
333
|
]);
|
|
334
|
+
expect(schemas[0]?.fields[2]).not.toHaveProperty('options');
|
|
331
335
|
});
|
|
332
|
-
it('prefers question prompts over nearby explanatory copy
|
|
336
|
+
it('includes explicit options when requested and prefers question prompts over nearby explanatory copy', () => {
|
|
333
337
|
const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
|
|
334
338
|
children: [
|
|
335
339
|
node('form', 'Application', { x: 20, y: 20, width: 760, height: 480 }, {
|
|
@@ -358,11 +362,16 @@ describe('buildFormSchemas', () => {
|
|
|
358
362
|
}),
|
|
359
363
|
],
|
|
360
364
|
});
|
|
361
|
-
const schema = buildFormSchemas(tree)[0];
|
|
365
|
+
const schema = buildFormSchemas(tree, { includeOptions: true, includeContext: 'always' })[0];
|
|
362
366
|
expect(schema?.fields[0]).toMatchObject({
|
|
363
367
|
kind: 'choice',
|
|
368
|
+
choiceType: 'group',
|
|
364
369
|
label: 'Will you now or in the future require sponsorship?',
|
|
365
370
|
options: ['Yes', 'No'],
|
|
371
|
+
booleanChoice: true,
|
|
372
|
+
context: {
|
|
373
|
+
section: 'Application',
|
|
374
|
+
},
|
|
366
375
|
});
|
|
367
376
|
});
|
|
368
377
|
});
|
package/dist/proxy-spawn.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
export interface EmbeddedProxyRuntime {
|
|
3
|
+
wsUrl: string;
|
|
4
|
+
closed: boolean;
|
|
5
|
+
close: () => Promise<void>;
|
|
6
|
+
}
|
|
2
7
|
/** Resolve bundled @geometra/proxy CLI entry (dist/index.js). */
|
|
3
8
|
export declare function resolveProxyScriptPath(): string;
|
|
4
9
|
export declare function resolveProxyScriptPathWith(customRequire: NodeRequire, moduleDir?: string): string;
|
|
10
|
+
export declare function resolveProxyRuntimePath(): string;
|
|
11
|
+
export declare function resolveProxyRuntimePathWith(customRequire: NodeRequire, moduleDir?: string): string;
|
|
5
12
|
export interface SpawnProxyParams {
|
|
6
13
|
pageUrl: string;
|
|
7
14
|
port: number;
|
|
@@ -10,6 +17,10 @@ export interface SpawnProxyParams {
|
|
|
10
17
|
height?: number;
|
|
11
18
|
slowMo?: number;
|
|
12
19
|
}
|
|
20
|
+
export declare function startEmbeddedGeometraProxy(opts: SpawnProxyParams): Promise<{
|
|
21
|
+
runtime: EmbeddedProxyRuntime;
|
|
22
|
+
wsUrl: string;
|
|
23
|
+
}>;
|
|
13
24
|
export declare function parseProxyReadySignalLine(line: string): string | undefined;
|
|
14
25
|
export declare function formatProxyStartupFailure(message: string, opts: SpawnProxyParams): string;
|
|
15
26
|
/**
|
package/dist/proxy-spawn.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, realpathSync, rmSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
7
|
const READY_SIGNAL_TYPE = 'geometra-proxy-ready';
|
|
8
8
|
const READY_TIMEOUT_MS = 45_000;
|
|
@@ -12,21 +12,30 @@ export function resolveProxyScriptPath() {
|
|
|
12
12
|
return resolveProxyScriptPathWith(require);
|
|
13
13
|
}
|
|
14
14
|
export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR) {
|
|
15
|
+
return resolveProxyDistPathWith(customRequire, moduleDir, 'index.js');
|
|
16
|
+
}
|
|
17
|
+
export function resolveProxyRuntimePath() {
|
|
18
|
+
return resolveProxyRuntimePathWith(require);
|
|
19
|
+
}
|
|
20
|
+
export function resolveProxyRuntimePathWith(customRequire, moduleDir = MODULE_DIR) {
|
|
21
|
+
return resolveProxyDistPathWith(customRequire, moduleDir, 'runtime.js');
|
|
22
|
+
}
|
|
23
|
+
function resolveProxyDistPathWith(customRequire, moduleDir, entryFile) {
|
|
15
24
|
const errors = [];
|
|
16
|
-
const workspaceDist = path.resolve(moduleDir,
|
|
25
|
+
const workspaceDist = path.resolve(moduleDir, `../../packages/proxy/dist/${entryFile}`);
|
|
17
26
|
const bundledDependencyDir = path.resolve(moduleDir, '../node_modules/@geometra/proxy');
|
|
18
27
|
const packageDir = resolveProxyPackageDir(customRequire);
|
|
19
28
|
if (packageDir) {
|
|
20
29
|
if (shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) && existsSync(workspaceDist)) {
|
|
21
30
|
return workspaceDist;
|
|
22
31
|
}
|
|
23
|
-
const packagedDist = path.join(packageDir, 'dist
|
|
32
|
+
const packagedDist = path.join(packageDir, 'dist', entryFile);
|
|
24
33
|
if (existsSync(packagedDist))
|
|
25
34
|
return packagedDist;
|
|
26
|
-
const builtLocalDist = buildLocalProxyDistIfPossible(packageDir, errors);
|
|
35
|
+
const builtLocalDist = buildLocalProxyDistIfPossible(packageDir, entryFile, errors);
|
|
27
36
|
if (builtLocalDist)
|
|
28
37
|
return builtLocalDist;
|
|
29
|
-
errors.push(`Resolved @geometra/proxy package at ${packageDir}, but dist
|
|
38
|
+
errors.push(`Resolved @geometra/proxy package at ${packageDir}, but dist/${entryFile} was missing`);
|
|
30
39
|
}
|
|
31
40
|
else {
|
|
32
41
|
errors.push('Could not find @geometra/proxy/package.json via Node module search paths');
|
|
@@ -34,24 +43,26 @@ export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR
|
|
|
34
43
|
try {
|
|
35
44
|
const pkgJson = customRequire.resolve('@geometra/proxy/package.json');
|
|
36
45
|
const exportPackageDir = path.dirname(pkgJson);
|
|
37
|
-
const packagedDist = path.join(exportPackageDir, 'dist
|
|
46
|
+
const packagedDist = path.join(exportPackageDir, 'dist', entryFile);
|
|
38
47
|
if (existsSync(packagedDist))
|
|
39
48
|
return packagedDist;
|
|
40
|
-
const builtLocalDist = buildLocalProxyDistIfPossible(exportPackageDir, errors);
|
|
49
|
+
const builtLocalDist = buildLocalProxyDistIfPossible(exportPackageDir, entryFile, errors);
|
|
41
50
|
if (builtLocalDist)
|
|
42
51
|
return builtLocalDist;
|
|
43
|
-
errors.push(`Resolved @geometra/proxy/package.json at ${pkgJson}, but dist
|
|
52
|
+
errors.push(`Resolved @geometra/proxy/package.json at ${pkgJson}, but dist/${entryFile} was missing`);
|
|
44
53
|
}
|
|
45
54
|
catch (err) {
|
|
46
55
|
errors.push(err instanceof Error ? err.message : String(err));
|
|
47
56
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
if (entryFile === 'index.js') {
|
|
58
|
+
try {
|
|
59
|
+
return customRequire.resolve('@geometra/proxy');
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
63
|
+
}
|
|
53
64
|
}
|
|
54
|
-
const packagedSiblingDist = path.resolve(moduleDir,
|
|
65
|
+
const packagedSiblingDist = path.resolve(moduleDir, `../../proxy/dist/${entryFile}`);
|
|
55
66
|
if (existsSync(packagedSiblingDist)) {
|
|
56
67
|
return packagedSiblingDist;
|
|
57
68
|
}
|
|
@@ -60,7 +71,7 @@ export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR
|
|
|
60
71
|
return workspaceDist;
|
|
61
72
|
}
|
|
62
73
|
errors.push(`Workspace fallback not found at ${workspaceDist}`);
|
|
63
|
-
throw new Error(`Could not resolve @geometra/proxy. Install it with the MCP package: npm install @geometra/proxy. Resolution errors: ${errors.join(' | ')}`);
|
|
74
|
+
throw new Error(`Could not resolve @geometra/proxy dist/${entryFile}. Install it with the MCP package: npm install @geometra/proxy. Resolution errors: ${errors.join(' | ')}`);
|
|
64
75
|
}
|
|
65
76
|
function resolveProxyPackageDir(customRequire) {
|
|
66
77
|
const searchRoots = customRequire.resolve.paths('@geometra/proxy') ?? [];
|
|
@@ -79,8 +90,8 @@ function shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) {
|
|
|
79
90
|
return false;
|
|
80
91
|
}
|
|
81
92
|
}
|
|
82
|
-
function buildLocalProxyDistIfPossible(packageDir, errors) {
|
|
83
|
-
const distEntry = path.join(packageDir, 'dist
|
|
93
|
+
function buildLocalProxyDistIfPossible(packageDir, entryFile, errors) {
|
|
94
|
+
const distEntry = path.join(packageDir, 'dist', entryFile);
|
|
84
95
|
const sourceEntry = path.join(packageDir, 'src/index.ts');
|
|
85
96
|
const tsconfigPath = path.join(packageDir, 'tsconfig.build.json');
|
|
86
97
|
if (!existsSync(sourceEntry) || !existsSync(tsconfigPath)) {
|
|
@@ -104,16 +115,32 @@ function buildLocalProxyDistIfPossible(packageDir, errors) {
|
|
|
104
115
|
}
|
|
105
116
|
if (existsSync(distEntry))
|
|
106
117
|
return distEntry;
|
|
107
|
-
const realDistEntry = path.join(realPackageDir, 'dist
|
|
118
|
+
const realDistEntry = path.join(realPackageDir, 'dist', entryFile);
|
|
108
119
|
if (existsSync(realDistEntry))
|
|
109
120
|
return realDistEntry;
|
|
110
|
-
errors.push(`Built local @geometra/proxy at ${realPackageDir}, but dist
|
|
121
|
+
errors.push(`Built local @geometra/proxy at ${realPackageDir}, but dist/${entryFile} is still missing`);
|
|
111
122
|
}
|
|
112
123
|
catch (err) {
|
|
113
124
|
errors.push(err instanceof Error ? err.message : String(err));
|
|
114
125
|
}
|
|
115
126
|
return undefined;
|
|
116
127
|
}
|
|
128
|
+
export async function startEmbeddedGeometraProxy(opts) {
|
|
129
|
+
const runtimePath = resolveProxyRuntimePath();
|
|
130
|
+
const runtimeModule = await import(pathToFileURL(runtimePath).href);
|
|
131
|
+
if (typeof runtimeModule.launchProxyRuntime !== 'function') {
|
|
132
|
+
throw new Error(`Resolved ${runtimePath}, but it did not export launchProxyRuntime()`);
|
|
133
|
+
}
|
|
134
|
+
const runtime = await runtimeModule.launchProxyRuntime({
|
|
135
|
+
url: opts.pageUrl,
|
|
136
|
+
port: opts.port,
|
|
137
|
+
width: opts.width,
|
|
138
|
+
height: opts.height,
|
|
139
|
+
headed: opts.headless !== true,
|
|
140
|
+
slowMo: opts.slowMo,
|
|
141
|
+
});
|
|
142
|
+
return { runtime, wsUrl: runtime.wsUrl };
|
|
143
|
+
}
|
|
117
144
|
export function parseProxyReadySignalLine(line) {
|
|
118
145
|
const trimmed = line.trim();
|
|
119
146
|
if (!trimmed)
|