@geometra/mcp 1.19.22 → 1.19.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -15
- package/dist/__tests__/proxy-session-recovery.test.js +91 -2
- package/dist/__tests__/server-batch-results.test.js +261 -1
- package/dist/__tests__/session-model.test.js +48 -0
- package/dist/proxy-spawn.d.ts +3 -0
- package/dist/proxy-spawn.js +3 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +468 -64
- package/dist/session.d.ts +37 -0
- package/dist/session.js +298 -31
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -24,18 +24,20 @@ Proxy-backed sessions stay warm by default on disconnect, and MCP now keeps a sm
|
|
|
24
24
|
|
|
25
25
|
| Tool | Description |
|
|
26
26
|
|---|---|
|
|
27
|
-
| `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; can inline `formSchema` and/or `pageModel
|
|
28
|
-
| `
|
|
27
|
+
| `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; can inline `formSchema` and/or `pageModel`, or defer the page model for a faster first connect response |
|
|
28
|
+
| `geometra_prepare_browser` | Pre-launch and pre-navigate a reusable proxy/browser for `pageUrl` without creating an active session; best when the agent can prepare before the user-facing task starts |
|
|
29
|
+
| `geometra_query` | Find elements by stable id, role, name, text content, prompt/section/item context, current value, or semantic state such as `invalid`, `required`, or `busy` |
|
|
29
30
|
| `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.). **Strict parameters** — use `text` plus `present: false` to wait until a substring disappears (e.g. “Parsing your resume”); there is no `textGone` field |
|
|
30
31
|
| `geometra_wait_for_resume_parse` | Convenience wait until a parsing banner is gone (defaults: `text`: `"Parsing"`, `present` implied false). Same engine as `geometra_wait_for` |
|
|
31
32
|
| `geometra_form_schema` | Compact, fill-oriented form schema with stable field ids and collapsed radio/button groups; can auto-connect from `pageUrl` / `url` |
|
|
32
33
|
| `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 |
|
|
33
|
-
| `geometra_fill_fields` | Fill text/choice/toggle/file fields in one MCP call; text/choice/toggle entries can use `fieldId` from `geometra_form_schema` without repeating the label |
|
|
34
|
-
| `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip
|
|
35
|
-
| `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
|
|
34
|
+
| `geometra_fill_fields` | Fill text/choice/toggle/file fields in one MCP call; text-only batches now use the proxy fast path when step output is omitted, and text/choice/toggle entries can use `fieldId` from `geometra_form_schema` without repeating the label |
|
|
35
|
+
| `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip; can auto-connect from `pageUrl` / `url` and return a final-only payload for the smallest multi-step responses |
|
|
36
|
+
| `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, and primary actions with nearby section/item context when available |
|
|
37
|
+
| `geometra_find_action` | Resolve a repeated button/link by action label plus optional `sectionText`, `promptText`, or `itemText` before clicking |
|
|
36
38
|
| `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand, with paging/filtering for long sections |
|
|
37
39
|
| `geometra_reveal` | Scroll until a matching node is visible instead of guessing wheel deltas; auto-scales reveal steps for tall forms when omitted |
|
|
38
|
-
| `geometra_click` | Click by coordinates or semantic target, optionally waiting for a post-click semantic condition |
|
|
40
|
+
| `geometra_click` | Click by coordinates or semantic target, including repeated-action disambiguation with `promptText`, `sectionText`, or `itemText`, optionally waiting for a post-click semantic condition |
|
|
39
41
|
| `geometra_type` | Type text into the focused element |
|
|
40
42
|
| `geometra_key` | Send special keys (Enter, Tab, Escape, arrows) |
|
|
41
43
|
| `geometra_upload_files` | Attach files: labeled field / auto / hidden input / native chooser / synthetic drop (`@geometra/proxy` only) |
|
|
@@ -323,10 +325,11 @@ Agent: geometra_query({ role: "button", name: "Save" })
|
|
|
323
325
|
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`.
|
|
324
326
|
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.
|
|
325
327
|
7. **`geometra_fill_fields`** is the lower-level field-intent path when you need direct control; text / choice / toggle entries can now resolve `fieldId` from `geometra_form_schema` without repeating labels.
|
|
326
|
-
8. **`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.
|
|
327
|
-
9. **`
|
|
328
|
-
10.
|
|
329
|
-
11. After
|
|
328
|
+
8. **`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 with nearby context.
|
|
329
|
+
9. **`geometra_find_action`** is the narrow repeated-action resolver when the page has many identical buttons/links and you want one exact target by `itemText`, `sectionText`, or `promptText`.
|
|
330
|
+
10. **`geometra_expand_section`** fetches richer details only for the section you care about (fields, actions, headings, nested lists, list items, text preview).
|
|
331
|
+
11. 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.
|
|
332
|
+
12. After each interaction, the peer sends updated geometry (full `frame` or `patch`) — the MCP tools interpret that into compact summaries.
|
|
330
333
|
|
|
331
334
|
## Long Forms
|
|
332
335
|
|
|
@@ -338,9 +341,12 @@ For long application flows, prefer one of these patterns:
|
|
|
338
341
|
4. `geometra_snapshot({ view: "form-required" })` when you need the remaining required fields including offscreen ones
|
|
339
342
|
5. `geometra_reveal` for far-below-fold targets such as submit buttons (auto-scales reveal steps when you omit `maxSteps`)
|
|
340
343
|
6. `geometra_click({ ..., waitFor: ... })` when one action should also wait for the next semantic state
|
|
341
|
-
7. `
|
|
342
|
-
8. `
|
|
343
|
-
9. `
|
|
344
|
+
7. `geometra_prepare_browser({ pageUrl, headless, width, height })` when you can hide browser startup before the real task and want the next proxy-backed flow to attach warm
|
|
345
|
+
8. `geometra_run_actions` when you need mixed navigation + waits + field entry, especially with `pageUrl` / `url` for a one-call flow
|
|
346
|
+
9. `geometra_connect({ pageUrl, returnPageModel: true })` when you want connect + summary-first exploration in one turn
|
|
347
|
+
10. `geometra_connect({ pageUrl, returnPageModel: true, pageModelMode: "deferred" })` when first-response latency matters more than inlining the page model; follow with `geometra_page_model`
|
|
348
|
+
11. `geometra_find_action` or `geometra_click({ name, itemText / sectionText / promptText, ... })` when the target action repeats across cards/rows
|
|
349
|
+
12. `geometra_page_model` + `geometra_expand_section` when you are still exploring the page rather than filling it
|
|
344
350
|
|
|
345
351
|
Typical batch:
|
|
346
352
|
|
|
@@ -355,12 +361,14 @@ Typical batch:
|
|
|
355
361
|
}
|
|
356
362
|
```
|
|
357
363
|
|
|
358
|
-
Single action tools
|
|
364
|
+
Single action tools default to short human-readable summaries (`detail: "minimal"`). Use `detail: "terse"` for the smallest machine-friendly JSON responses, or `detail: "verbose"` when you need a fuller current-UI fallback for debugging.
|
|
359
365
|
|
|
360
366
|
For the smallest long-form responses, prefer:
|
|
361
367
|
|
|
362
|
-
1. `detail: "
|
|
368
|
+
1. `detail: "terse"` for compact machine-friendly action responses
|
|
363
369
|
2. `includeSteps: false` when you only need aggregate success/error counts plus the final validation/state payload
|
|
370
|
+
3. `output: "final"` on `geometra_run_actions` when you only need completion state plus the final semantic signals
|
|
371
|
+
4. `geometra_prepare_browser` before the flow when you can shift browser launch out of the user-visible task window
|
|
364
372
|
|
|
365
373
|
Typical low-token form fill:
|
|
366
374
|
|
|
@@ -8,7 +8,7 @@ vi.mock('../proxy-spawn.js', () => ({
|
|
|
8
8
|
startEmbeddedGeometraProxy: mockState.startEmbeddedGeometraProxy,
|
|
9
9
|
spawnGeometraProxy: mockState.spawnGeometraProxy,
|
|
10
10
|
}));
|
|
11
|
-
const { connectThroughProxy, disconnect } = await import('../session.js');
|
|
11
|
+
const { connectThroughProxy, disconnect, prewarmProxy } = await import('../session.js');
|
|
12
12
|
function frame(pageUrl) {
|
|
13
13
|
return {
|
|
14
14
|
type: 'frame',
|
|
@@ -28,12 +28,17 @@ function frame(pageUrl) {
|
|
|
28
28
|
async function createProxyPeer(options) {
|
|
29
29
|
const wss = new WebSocketServer({ port: 0 });
|
|
30
30
|
wss.on('connection', ws => {
|
|
31
|
-
|
|
31
|
+
if (options?.sendInitialFrame !== false) {
|
|
32
|
+
ws.send(JSON.stringify(frame(options?.pageUrl ?? 'https://jobs.example.com/original')));
|
|
33
|
+
}
|
|
32
34
|
ws.on('message', raw => {
|
|
33
35
|
const msg = JSON.parse(String(raw));
|
|
34
36
|
if (msg.type === 'navigate') {
|
|
35
37
|
options?.onNavigate?.(ws, msg);
|
|
36
38
|
}
|
|
39
|
+
else if (msg.type === 'resize') {
|
|
40
|
+
options?.onResize?.(ws, msg);
|
|
41
|
+
}
|
|
37
42
|
});
|
|
38
43
|
});
|
|
39
44
|
const port = await new Promise((resolve, reject) => {
|
|
@@ -78,6 +83,7 @@ describe('connectThroughProxy recovery', () => {
|
|
|
78
83
|
});
|
|
79
84
|
const staleRuntime = {
|
|
80
85
|
wsUrl: stalePeer.wsUrl,
|
|
86
|
+
ready: Promise.resolve(),
|
|
81
87
|
closed: false,
|
|
82
88
|
close: vi.fn(async () => {
|
|
83
89
|
staleRuntime.closed = true;
|
|
@@ -85,6 +91,7 @@ describe('connectThroughProxy recovery', () => {
|
|
|
85
91
|
};
|
|
86
92
|
const freshRuntime = {
|
|
87
93
|
wsUrl: freshPeer.wsUrl,
|
|
94
|
+
ready: Promise.resolve(),
|
|
88
95
|
closed: false,
|
|
89
96
|
close: vi.fn(async () => {
|
|
90
97
|
freshRuntime.closed = true;
|
|
@@ -124,6 +131,7 @@ describe('connectThroughProxy recovery', () => {
|
|
|
124
131
|
});
|
|
125
132
|
const headedRuntime = {
|
|
126
133
|
wsUrl: headedPeer.wsUrl,
|
|
134
|
+
ready: Promise.resolve(),
|
|
127
135
|
closed: false,
|
|
128
136
|
close: vi.fn(async () => {
|
|
129
137
|
headedRuntime.closed = true;
|
|
@@ -131,6 +139,7 @@ describe('connectThroughProxy recovery', () => {
|
|
|
131
139
|
};
|
|
132
140
|
const headlessRuntime = {
|
|
133
141
|
wsUrl: headlessPeer.wsUrl,
|
|
142
|
+
ready: Promise.resolve(),
|
|
134
143
|
closed: false,
|
|
135
144
|
close: vi.fn(async () => {
|
|
136
145
|
headlessRuntime.closed = true;
|
|
@@ -170,4 +179,84 @@ describe('connectThroughProxy recovery', () => {
|
|
|
170
179
|
await closePeer(headlessPeer.wss);
|
|
171
180
|
}
|
|
172
181
|
});
|
|
182
|
+
it('can prewarm a reusable proxy before the first measured task', async () => {
|
|
183
|
+
const preparedPeer = await createProxyPeer({
|
|
184
|
+
pageUrl: 'https://jobs.example.com/prepared',
|
|
185
|
+
});
|
|
186
|
+
const preparedRuntime = {
|
|
187
|
+
wsUrl: preparedPeer.wsUrl,
|
|
188
|
+
ready: Promise.resolve(),
|
|
189
|
+
closed: false,
|
|
190
|
+
close: vi.fn(async () => {
|
|
191
|
+
preparedRuntime.closed = true;
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
mockState.startEmbeddedGeometraProxy.mockResolvedValue({
|
|
195
|
+
runtime: preparedRuntime,
|
|
196
|
+
wsUrl: preparedPeer.wsUrl,
|
|
197
|
+
});
|
|
198
|
+
mockState.spawnGeometraProxy.mockRejectedValue(new Error('spawn fallback should not be used'));
|
|
199
|
+
try {
|
|
200
|
+
const prepared = await prewarmProxy({
|
|
201
|
+
pageUrl: 'https://jobs.example.com/prepared',
|
|
202
|
+
headless: true,
|
|
203
|
+
});
|
|
204
|
+
expect(prepared).toMatchObject({
|
|
205
|
+
prepared: true,
|
|
206
|
+
reused: false,
|
|
207
|
+
transport: 'embedded',
|
|
208
|
+
pageUrl: 'https://jobs.example.com/prepared',
|
|
209
|
+
});
|
|
210
|
+
const session = await connectThroughProxy({
|
|
211
|
+
pageUrl: 'https://jobs.example.com/prepared',
|
|
212
|
+
headless: true,
|
|
213
|
+
});
|
|
214
|
+
expect(session.proxyRuntime).toBe(preparedRuntime);
|
|
215
|
+
expect(mockState.startEmbeddedGeometraProxy).toHaveBeenCalledTimes(1);
|
|
216
|
+
expect(mockState.spawnGeometraProxy).not.toHaveBeenCalled();
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
disconnect({ closeProxy: true });
|
|
220
|
+
expect(preparedRuntime.close).toHaveBeenCalledTimes(1);
|
|
221
|
+
await closePeer(preparedPeer.wss);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
it('starts without an eager initial extract when the caller defers the first frame', async () => {
|
|
225
|
+
const lazyPeer = await createProxyPeer({
|
|
226
|
+
pageUrl: 'https://jobs.example.com/lazy',
|
|
227
|
+
sendInitialFrame: false,
|
|
228
|
+
});
|
|
229
|
+
const lazyRuntime = {
|
|
230
|
+
wsUrl: lazyPeer.wsUrl,
|
|
231
|
+
ready: Promise.resolve(),
|
|
232
|
+
closed: false,
|
|
233
|
+
close: vi.fn(async () => {
|
|
234
|
+
lazyRuntime.closed = true;
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
mockState.startEmbeddedGeometraProxy.mockResolvedValueOnce({
|
|
238
|
+
runtime: lazyRuntime,
|
|
239
|
+
wsUrl: lazyPeer.wsUrl,
|
|
240
|
+
});
|
|
241
|
+
mockState.spawnGeometraProxy.mockRejectedValue(new Error('spawn fallback should not be used'));
|
|
242
|
+
try {
|
|
243
|
+
const session = await connectThroughProxy({
|
|
244
|
+
pageUrl: 'https://jobs.example.com/lazy',
|
|
245
|
+
headless: true,
|
|
246
|
+
awaitInitialFrame: false,
|
|
247
|
+
});
|
|
248
|
+
expect(session.proxyRuntime).toBe(lazyRuntime);
|
|
249
|
+
expect(mockState.startEmbeddedGeometraProxy).toHaveBeenCalledWith(expect.objectContaining({
|
|
250
|
+
pageUrl: 'https://jobs.example.com/lazy',
|
|
251
|
+
headless: true,
|
|
252
|
+
eagerInitialExtract: false,
|
|
253
|
+
}));
|
|
254
|
+
expect(session.tree).toBeNull();
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
disconnect({ closeProxy: true });
|
|
258
|
+
expect(lazyRuntime.close).toHaveBeenCalledTimes(1);
|
|
259
|
+
await closePeer(lazyPeer.wss);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
173
262
|
});
|
|
@@ -17,6 +17,7 @@ const mockState = vi.hoisted(() => ({
|
|
|
17
17
|
currentA11yRoot: node('group', undefined, {
|
|
18
18
|
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
19
19
|
}),
|
|
20
|
+
nodeContexts: new Map(),
|
|
20
21
|
session: {
|
|
21
22
|
tree: { kind: 'box' },
|
|
22
23
|
layout: { x: 0, y: 0, width: 1280, height: 800, children: [] },
|
|
@@ -29,6 +30,7 @@ const mockState = vi.hoisted(() => ({
|
|
|
29
30
|
formSchemas: [],
|
|
30
31
|
connect: vi.fn(),
|
|
31
32
|
connectThroughProxy: vi.fn(),
|
|
33
|
+
prewarmProxy: vi.fn(),
|
|
32
34
|
sendClick: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
33
35
|
sendType: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
34
36
|
sendKey: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
@@ -44,13 +46,14 @@ const mockState = vi.hoisted(() => ({
|
|
|
44
46
|
sendSelectOption: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
45
47
|
sendSetChecked: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
46
48
|
sendWheel: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
47
|
-
waitForUiCondition: vi.fn(async () => true),
|
|
49
|
+
waitForUiCondition: vi.fn(async (_session, _check, _timeoutMs) => true),
|
|
48
50
|
}));
|
|
49
51
|
function resetMockSessionCaches() {
|
|
50
52
|
mockState.session.updateRevision = 1;
|
|
51
53
|
mockState.session.cachedA11y = undefined;
|
|
52
54
|
mockState.session.cachedA11yRevision = undefined;
|
|
53
55
|
mockState.session.cachedFormSchemas = undefined;
|
|
56
|
+
mockState.nodeContexts.clear();
|
|
54
57
|
}
|
|
55
58
|
function bumpMockUiRevision() {
|
|
56
59
|
mockState.session.updateRevision += 1;
|
|
@@ -61,6 +64,7 @@ function bumpMockUiRevision() {
|
|
|
61
64
|
vi.mock('../session.js', () => ({
|
|
62
65
|
connect: mockState.connect,
|
|
63
66
|
connectThroughProxy: mockState.connectThroughProxy,
|
|
67
|
+
prewarmProxy: mockState.prewarmProxy,
|
|
64
68
|
disconnect: vi.fn(),
|
|
65
69
|
getSession: vi.fn(() => mockState.session),
|
|
66
70
|
sendClick: mockState.sendClick,
|
|
@@ -95,6 +99,7 @@ vi.mock('../session.js', () => ({
|
|
|
95
99
|
summarizeCompactIndex: vi.fn(() => ''),
|
|
96
100
|
summarizePageModel: vi.fn(() => ''),
|
|
97
101
|
summarizeUiDelta: vi.fn(() => ''),
|
|
102
|
+
nodeContextForNode: vi.fn((_, node) => mockState.nodeContexts.get((node.path ?? []).join('.'))),
|
|
98
103
|
waitForUiCondition: mockState.waitForUiCondition,
|
|
99
104
|
}));
|
|
100
105
|
const { createServer } = await import('../server.js');
|
|
@@ -108,6 +113,16 @@ describe('batch MCP result shaping', () => {
|
|
|
108
113
|
resetMockSessionCaches();
|
|
109
114
|
mockState.connect.mockResolvedValue(mockState.session);
|
|
110
115
|
mockState.connectThroughProxy.mockResolvedValue(mockState.session);
|
|
116
|
+
mockState.prewarmProxy.mockResolvedValue({
|
|
117
|
+
prepared: true,
|
|
118
|
+
reused: false,
|
|
119
|
+
transport: 'embedded',
|
|
120
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
121
|
+
wsUrl: 'ws://127.0.0.1:3200',
|
|
122
|
+
headless: true,
|
|
123
|
+
width: 1280,
|
|
124
|
+
height: 720,
|
|
125
|
+
});
|
|
111
126
|
mockState.formSchemas = [];
|
|
112
127
|
mockState.currentA11yRoot = node('group', undefined, {
|
|
113
128
|
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
|
|
@@ -269,6 +284,112 @@ describe('batch MCP result shaping', () => {
|
|
|
269
284
|
expect(final.invalidFields.length).toBe(4);
|
|
270
285
|
expect(final.alerts.length).toBe(1);
|
|
271
286
|
});
|
|
287
|
+
it('uses the proxy batch path for fill_fields when step output is omitted', async () => {
|
|
288
|
+
const handler = getToolHandler('geometra_fill_fields');
|
|
289
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
290
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
|
|
291
|
+
children: [
|
|
292
|
+
node('textbox', 'Full name', { value: 'Taylor Applicant', path: [0] }),
|
|
293
|
+
node('textbox', 'Email', { value: 'taylor@example.com', path: [1] }),
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
const result = await handler({
|
|
297
|
+
fields: [
|
|
298
|
+
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
299
|
+
{ kind: 'text', fieldLabel: 'Email', value: 'taylor@example.com' },
|
|
300
|
+
],
|
|
301
|
+
stopOnError: true,
|
|
302
|
+
failOnInvalid: false,
|
|
303
|
+
includeSteps: false,
|
|
304
|
+
detail: 'terse',
|
|
305
|
+
});
|
|
306
|
+
const payload = JSON.parse(result.content[0].text);
|
|
307
|
+
expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
|
|
308
|
+
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
309
|
+
{ kind: 'text', fieldLabel: 'Email', value: 'taylor@example.com' },
|
|
310
|
+
]);
|
|
311
|
+
expect(mockState.sendFieldText).not.toHaveBeenCalled();
|
|
312
|
+
expect(payload).toMatchObject({
|
|
313
|
+
completed: true,
|
|
314
|
+
execution: 'batched',
|
|
315
|
+
finalSource: 'session',
|
|
316
|
+
fieldCount: 2,
|
|
317
|
+
successCount: 2,
|
|
318
|
+
errorCount: 0,
|
|
319
|
+
final: { invalidCount: 0 },
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
it('auto-connects run_actions and supports final-only output', async () => {
|
|
323
|
+
const handler = getToolHandler('geometra_run_actions');
|
|
324
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
325
|
+
meta: { pageUrl: 'https://shop.example.com/login', scrollX: 0, scrollY: 0 },
|
|
326
|
+
children: [
|
|
327
|
+
node('textbox', 'Username', { value: 'standard_user', path: [0] }),
|
|
328
|
+
node('textbox', 'Password', { value: 'secret_sauce', path: [1] }),
|
|
329
|
+
],
|
|
330
|
+
});
|
|
331
|
+
const result = await handler({
|
|
332
|
+
pageUrl: 'https://shop.example.com/login',
|
|
333
|
+
headless: true,
|
|
334
|
+
actions: [
|
|
335
|
+
{
|
|
336
|
+
type: 'fill_fields',
|
|
337
|
+
fields: [
|
|
338
|
+
{ kind: 'text', fieldLabel: 'Username', value: 'standard_user' },
|
|
339
|
+
{ kind: 'text', fieldLabel: 'Password', value: 'secret_sauce' },
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
stopOnError: true,
|
|
344
|
+
includeSteps: false,
|
|
345
|
+
output: 'final',
|
|
346
|
+
detail: 'terse',
|
|
347
|
+
});
|
|
348
|
+
const payload = JSON.parse(result.content[0].text);
|
|
349
|
+
expect(mockState.connectThroughProxy).toHaveBeenCalledWith(expect.objectContaining({
|
|
350
|
+
pageUrl: 'https://shop.example.com/login',
|
|
351
|
+
headless: true,
|
|
352
|
+
awaitInitialFrame: false,
|
|
353
|
+
}));
|
|
354
|
+
expect(payload).toMatchObject({
|
|
355
|
+
autoConnected: true,
|
|
356
|
+
transport: 'proxy',
|
|
357
|
+
pageUrl: 'https://shop.example.com/login',
|
|
358
|
+
completed: true,
|
|
359
|
+
final: { invalidCount: 0 },
|
|
360
|
+
});
|
|
361
|
+
expect(payload).not.toHaveProperty('steps');
|
|
362
|
+
expect(payload).not.toHaveProperty('stepCount');
|
|
363
|
+
});
|
|
364
|
+
it('finds repeated actions by itemText in terse mode', async () => {
|
|
365
|
+
const handler = getToolHandler('geometra_find_action');
|
|
366
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
367
|
+
bounds: { x: 0, y: 0, width: 1280, height: 720 },
|
|
368
|
+
meta: { pageUrl: 'https://shop.example.com/inventory', scrollX: 0, scrollY: 0 },
|
|
369
|
+
children: [
|
|
370
|
+
node('button', 'Add to cart', { path: [0], bounds: { x: 40, y: 160, width: 120, height: 36 } }),
|
|
371
|
+
node('button', 'Add to cart', { path: [1], bounds: { x: 40, y: 260, width: 120, height: 36 } }),
|
|
372
|
+
],
|
|
373
|
+
});
|
|
374
|
+
mockState.nodeContexts.set('0', { item: 'Sauce Labs Backpack', section: 'Inventory' });
|
|
375
|
+
mockState.nodeContexts.set('1', { item: 'Sauce Labs Bike Light', section: 'Inventory' });
|
|
376
|
+
const result = await handler({
|
|
377
|
+
name: 'Add to cart',
|
|
378
|
+
itemText: 'Backpack',
|
|
379
|
+
detail: 'terse',
|
|
380
|
+
maxResults: 4,
|
|
381
|
+
});
|
|
382
|
+
const payload = JSON.parse(result.content[0].text);
|
|
383
|
+
const matches = payload.matches;
|
|
384
|
+
expect(payload.matchCount).toBe(1);
|
|
385
|
+
expect(matches[0]).toMatchObject({
|
|
386
|
+
id: 'n:0',
|
|
387
|
+
role: 'button',
|
|
388
|
+
name: 'Add to cart',
|
|
389
|
+
context: { item: 'Sauce Labs Backpack', section: 'Inventory' },
|
|
390
|
+
center: { x: 100, y: 178 },
|
|
391
|
+
});
|
|
392
|
+
});
|
|
272
393
|
it('returns a compact structured connect payload by default', async () => {
|
|
273
394
|
const handler = getToolHandler('geometra_connect');
|
|
274
395
|
const result = await handler({
|
|
@@ -284,6 +405,34 @@ describe('batch MCP result shaping', () => {
|
|
|
284
405
|
});
|
|
285
406
|
expect(payload).not.toHaveProperty('currentUi');
|
|
286
407
|
});
|
|
408
|
+
it('prepares a warm browser without creating an active session', async () => {
|
|
409
|
+
const handler = getToolHandler('geometra_prepare_browser');
|
|
410
|
+
const result = await handler({
|
|
411
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
412
|
+
headless: true,
|
|
413
|
+
width: 1280,
|
|
414
|
+
height: 720,
|
|
415
|
+
});
|
|
416
|
+
const payload = JSON.parse(result.content[0].text);
|
|
417
|
+
expect(mockState.prewarmProxy).toHaveBeenCalledWith({
|
|
418
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
419
|
+
port: undefined,
|
|
420
|
+
headless: true,
|
|
421
|
+
width: 1280,
|
|
422
|
+
height: 720,
|
|
423
|
+
slowMo: undefined,
|
|
424
|
+
});
|
|
425
|
+
expect(payload).toMatchObject({
|
|
426
|
+
prepared: true,
|
|
427
|
+
reused: false,
|
|
428
|
+
transport: 'embedded',
|
|
429
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
430
|
+
headless: true,
|
|
431
|
+
width: 1280,
|
|
432
|
+
height: 720,
|
|
433
|
+
});
|
|
434
|
+
expect(mockState.connectThroughProxy).not.toHaveBeenCalled();
|
|
435
|
+
});
|
|
287
436
|
it('can inline a packed form schema into connect for the low-turn form path', async () => {
|
|
288
437
|
const handler = getToolHandler('geometra_connect');
|
|
289
438
|
mockState.formSchemas = [
|
|
@@ -353,6 +502,72 @@ describe('batch MCP result shaping', () => {
|
|
|
353
502
|
});
|
|
354
503
|
expect(payload).not.toHaveProperty('formSchema');
|
|
355
504
|
});
|
|
505
|
+
it('can defer the page model so connect returns before the first frame', async () => {
|
|
506
|
+
const handler = getToolHandler('geometra_connect');
|
|
507
|
+
mockState.session.tree = null;
|
|
508
|
+
mockState.session.layout = null;
|
|
509
|
+
const result = await handler({
|
|
510
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
511
|
+
headless: true,
|
|
512
|
+
returnPageModel: true,
|
|
513
|
+
pageModelMode: 'deferred',
|
|
514
|
+
maxPrimaryActions: 4,
|
|
515
|
+
maxSectionsPerKind: 3,
|
|
516
|
+
});
|
|
517
|
+
const payload = JSON.parse(result.content[0].text);
|
|
518
|
+
expect(mockState.connectThroughProxy).toHaveBeenCalledWith({
|
|
519
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
520
|
+
port: undefined,
|
|
521
|
+
headless: true,
|
|
522
|
+
width: undefined,
|
|
523
|
+
height: undefined,
|
|
524
|
+
slowMo: undefined,
|
|
525
|
+
awaitInitialFrame: false,
|
|
526
|
+
eagerInitialExtract: true,
|
|
527
|
+
});
|
|
528
|
+
expect(payload).toMatchObject({
|
|
529
|
+
connected: true,
|
|
530
|
+
transport: 'proxy',
|
|
531
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
532
|
+
pageModel: {
|
|
533
|
+
deferred: true,
|
|
534
|
+
ready: false,
|
|
535
|
+
tool: 'geometra_page_model',
|
|
536
|
+
options: {
|
|
537
|
+
maxPrimaryActions: 4,
|
|
538
|
+
maxSectionsPerKind: 3,
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
it('waits for the initial tree when page_model is requested after a deferred connect', async () => {
|
|
544
|
+
const handler = getToolHandler('geometra_page_model');
|
|
545
|
+
mockState.session.tree = null;
|
|
546
|
+
mockState.session.layout = null;
|
|
547
|
+
mockState.waitForUiCondition.mockImplementationOnce(async (_session, check) => {
|
|
548
|
+
mockState.session.tree = { kind: 'box' };
|
|
549
|
+
mockState.session.layout = {
|
|
550
|
+
x: 0,
|
|
551
|
+
y: 0,
|
|
552
|
+
width: 1280,
|
|
553
|
+
height: 800,
|
|
554
|
+
children: [],
|
|
555
|
+
};
|
|
556
|
+
bumpMockUiRevision();
|
|
557
|
+
return check();
|
|
558
|
+
});
|
|
559
|
+
const result = await handler({
|
|
560
|
+
maxPrimaryActions: 4,
|
|
561
|
+
maxSectionsPerKind: 3,
|
|
562
|
+
});
|
|
563
|
+
const payload = JSON.parse(result.content[0].text);
|
|
564
|
+
expect(mockState.waitForUiCondition).toHaveBeenCalledWith(mockState.session, expect.any(Function), 4000);
|
|
565
|
+
expect(payload).toMatchObject({
|
|
566
|
+
viewport: { width: 1280, height: 800 },
|
|
567
|
+
archetypes: ['form'],
|
|
568
|
+
summary: { formCount: 1 },
|
|
569
|
+
});
|
|
570
|
+
});
|
|
356
571
|
it('returns compact form schemas without requiring section expansion', async () => {
|
|
357
572
|
const handler = getToolHandler('geometra_form_schema');
|
|
358
573
|
mockState.formSchemas = [
|
|
@@ -670,6 +885,14 @@ describe('query and reveal tools', () => {
|
|
|
670
885
|
}),
|
|
671
886
|
],
|
|
672
887
|
});
|
|
888
|
+
mockState.nodeContexts.set('0.0.1', {
|
|
889
|
+
prompt: 'Are you legally authorized to work here?',
|
|
890
|
+
section: 'Application',
|
|
891
|
+
});
|
|
892
|
+
mockState.nodeContexts.set('0.1.1', {
|
|
893
|
+
prompt: 'Will you require sponsorship?',
|
|
894
|
+
section: 'Application',
|
|
895
|
+
});
|
|
673
896
|
const result = await handler({
|
|
674
897
|
role: 'button',
|
|
675
898
|
name: 'Yes',
|
|
@@ -938,6 +1161,41 @@ describe('query and reveal tools', () => {
|
|
|
938
1161
|
expect(result.content[0].text).toContain('Post-click condition satisfied after');
|
|
939
1162
|
expect(result.content[0].text).toContain('1 matching node(s).');
|
|
940
1163
|
});
|
|
1164
|
+
it('waits for the initial tree before a semantic click after deferred connect', async () => {
|
|
1165
|
+
const handler = getToolHandler('geometra_click');
|
|
1166
|
+
mockState.session.tree = null;
|
|
1167
|
+
mockState.session.layout = null;
|
|
1168
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1169
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1170
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1171
|
+
children: [
|
|
1172
|
+
node('button', 'Open incident', {
|
|
1173
|
+
bounds: { x: 40, y: 120, width: 140, height: 40 },
|
|
1174
|
+
path: [0],
|
|
1175
|
+
}),
|
|
1176
|
+
],
|
|
1177
|
+
});
|
|
1178
|
+
mockState.waitForUiCondition.mockImplementationOnce(async (_session, check) => {
|
|
1179
|
+
mockState.session.tree = { kind: 'box' };
|
|
1180
|
+
mockState.session.layout = {
|
|
1181
|
+
x: 0,
|
|
1182
|
+
y: 0,
|
|
1183
|
+
width: 1280,
|
|
1184
|
+
height: 800,
|
|
1185
|
+
children: [],
|
|
1186
|
+
};
|
|
1187
|
+
bumpMockUiRevision();
|
|
1188
|
+
return check();
|
|
1189
|
+
});
|
|
1190
|
+
const result = await handler({
|
|
1191
|
+
role: 'button',
|
|
1192
|
+
name: 'Open incident',
|
|
1193
|
+
detail: 'minimal',
|
|
1194
|
+
});
|
|
1195
|
+
expect(mockState.waitForUiCondition).toHaveBeenCalledWith(mockState.session, expect.any(Function), 4000);
|
|
1196
|
+
expect(mockState.sendClick).toHaveBeenCalledWith(mockState.session, 110, 140, undefined);
|
|
1197
|
+
expect(result.content[0].text).toContain('Clicked button "Open incident" (n:0) at (110, 140).');
|
|
1198
|
+
});
|
|
941
1199
|
it('lets run_actions click a semantic target without manual coordinates', async () => {
|
|
942
1200
|
const handler = getToolHandler('geometra_run_actions');
|
|
943
1201
|
mockState.currentA11yRoot = node('group', undefined, {
|
|
@@ -998,6 +1256,7 @@ describe('query and reveal tools', () => {
|
|
|
998
1256
|
index: 0,
|
|
999
1257
|
type: 'click',
|
|
1000
1258
|
ok: true,
|
|
1259
|
+
elapsedMs: expect.any(Number),
|
|
1001
1260
|
at: { x: 150, y: 340 },
|
|
1002
1261
|
revealSteps: 1,
|
|
1003
1262
|
target: { id: 'n:0.0', role: 'button', name: 'Submit application' },
|
|
@@ -1057,6 +1316,7 @@ describe('query and reveal tools', () => {
|
|
|
1057
1316
|
index: 0,
|
|
1058
1317
|
type: 'click',
|
|
1059
1318
|
ok: true,
|
|
1319
|
+
elapsedMs: expect.any(Number),
|
|
1060
1320
|
postWait: {
|
|
1061
1321
|
present: true,
|
|
1062
1322
|
matchCount: 1,
|
|
@@ -75,6 +75,54 @@ describe('buildPageModel', () => {
|
|
|
75
75
|
}),
|
|
76
76
|
]);
|
|
77
77
|
});
|
|
78
|
+
it('adds nearby item context to repeated primary actions', () => {
|
|
79
|
+
const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
|
|
80
|
+
children: [
|
|
81
|
+
node('main', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
|
|
82
|
+
path: [0],
|
|
83
|
+
children: [
|
|
84
|
+
node('group', undefined, { x: 40, y: 80, width: 320, height: 140 }, {
|
|
85
|
+
path: [0, 0],
|
|
86
|
+
children: [
|
|
87
|
+
node('link', 'Sauce Labs Backpack', { x: 56, y: 96, width: 180, height: 24 }, {
|
|
88
|
+
path: [0, 0, 0],
|
|
89
|
+
focusable: true,
|
|
90
|
+
}),
|
|
91
|
+
node('button', 'Add to cart', { x: 56, y: 156, width: 120, height: 36 }, {
|
|
92
|
+
path: [0, 0, 1],
|
|
93
|
+
focusable: true,
|
|
94
|
+
}),
|
|
95
|
+
],
|
|
96
|
+
}),
|
|
97
|
+
node('group', undefined, { x: 40, y: 252, width: 320, height: 140 }, {
|
|
98
|
+
path: [0, 1],
|
|
99
|
+
children: [
|
|
100
|
+
node('link', 'Sauce Labs Bike Light', { x: 56, y: 268, width: 180, height: 24 }, {
|
|
101
|
+
path: [0, 1, 0],
|
|
102
|
+
focusable: true,
|
|
103
|
+
}),
|
|
104
|
+
node('button', 'Add to cart', { x: 56, y: 328, width: 120, height: 36 }, {
|
|
105
|
+
path: [0, 1, 1],
|
|
106
|
+
focusable: true,
|
|
107
|
+
}),
|
|
108
|
+
],
|
|
109
|
+
}),
|
|
110
|
+
],
|
|
111
|
+
}),
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
const model = buildPageModel(tree, { maxPrimaryActions: 4 });
|
|
115
|
+
const addToCartActions = model.primaryActions.filter(action => action.name === 'Add to cart');
|
|
116
|
+
expect(addToCartActions).toHaveLength(2);
|
|
117
|
+
expect(addToCartActions[0]).toMatchObject({
|
|
118
|
+
id: 'n:0.0.1',
|
|
119
|
+
context: { item: 'Sauce Labs Backpack' },
|
|
120
|
+
});
|
|
121
|
+
expect(addToCartActions[1]).toMatchObject({
|
|
122
|
+
id: 'n:0.1.1',
|
|
123
|
+
context: { item: 'Sauce Labs Bike Light' },
|
|
124
|
+
});
|
|
125
|
+
});
|
|
78
126
|
it('expands a section by id on demand', () => {
|
|
79
127
|
const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
|
|
80
128
|
children: [
|
package/dist/proxy-spawn.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type ChildProcess } from 'node:child_process';
|
|
2
2
|
export interface EmbeddedProxyRuntime {
|
|
3
3
|
wsUrl: string;
|
|
4
|
+
ready: Promise<void>;
|
|
5
|
+
getTrace?: () => Record<string, unknown>;
|
|
4
6
|
closed: boolean;
|
|
5
7
|
close: () => Promise<void>;
|
|
6
8
|
}
|
|
@@ -16,6 +18,7 @@ export interface SpawnProxyParams {
|
|
|
16
18
|
width?: number;
|
|
17
19
|
height?: number;
|
|
18
20
|
slowMo?: number;
|
|
21
|
+
eagerInitialExtract?: boolean;
|
|
19
22
|
}
|
|
20
23
|
export declare function startEmbeddedGeometraProxy(opts: SpawnProxyParams): Promise<{
|
|
21
24
|
runtime: EmbeddedProxyRuntime;
|