@geometra/mcp 1.19.22 → 1.20.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.
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` for lower-turn starts |
28
- | `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` |
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 and get one consolidated result, with optional final-only output |
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. **`geometra_expand_section`** fetches richer details only for the section you care about (fields, actions, headings, nested lists, list items, text preview).
328
- 10. 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.
329
- 11. After each interaction, the peer sends updated geometry (full `frame` or `patch`) the MCP tools interpret that into compact summaries.
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. `geometra_run_actions` when you need mixed navigation + waits + field entry
342
- 8. `geometra_connect({ pageUrl, returnPageModel: true })` when you want connect + summary-first exploration in one turn
343
- 9. `geometra_page_model` + `geometra_expand_section` when you are still exploring the page rather than filling it
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 now default to terse summaries. Pass `detail: "verbose"` when you need a fuller current-UI fallback for debugging.
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: "minimal"` for structured step metadata instead of narrated deltas
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
- ws.send(JSON.stringify(frame(options?.pageUrl ?? 'https://jobs.example.com/original')));
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 },
@@ -266,9 +281,115 @@ describe('batch MCP result shaping', () => {
266
281
  alertCount: 1,
267
282
  invalidCount: 5,
268
283
  });
269
- expect(final.invalidFields.length).toBe(4);
284
+ expect(final.invalidFields.length).toBe(5);
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: [
@@ -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;