@geometra/mcp 1.19.20 → 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 +47 -22
- package/dist/__tests__/proxy-session-recovery.test.js +91 -2
- package/dist/__tests__/server-batch-results.test.js +344 -1
- package/dist/__tests__/session-model.test.js +121 -1
- 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 +708 -95
- package/dist/session.d.ts +60 -0
- package/dist/session.js +343 -31
- package/package.json +2 -2
|
@@ -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,
|
|
@@ -87,6 +91,7 @@ vi.mock('../session.js', () => ({
|
|
|
87
91
|
lists: [],
|
|
88
92
|
})),
|
|
89
93
|
buildFormSchemas: vi.fn(() => mockState.formSchemas),
|
|
94
|
+
buildFormRequiredSnapshot: vi.fn(() => []),
|
|
90
95
|
expandPageSection: vi.fn(() => null),
|
|
91
96
|
buildUiDelta: vi.fn(() => ({})),
|
|
92
97
|
hasUiDelta: vi.fn(() => false),
|
|
@@ -94,6 +99,7 @@ vi.mock('../session.js', () => ({
|
|
|
94
99
|
summarizeCompactIndex: vi.fn(() => ''),
|
|
95
100
|
summarizePageModel: vi.fn(() => ''),
|
|
96
101
|
summarizeUiDelta: vi.fn(() => ''),
|
|
102
|
+
nodeContextForNode: vi.fn((_, node) => mockState.nodeContexts.get((node.path ?? []).join('.'))),
|
|
97
103
|
waitForUiCondition: mockState.waitForUiCondition,
|
|
98
104
|
}));
|
|
99
105
|
const { createServer } = await import('../server.js');
|
|
@@ -107,6 +113,16 @@ describe('batch MCP result shaping', () => {
|
|
|
107
113
|
resetMockSessionCaches();
|
|
108
114
|
mockState.connect.mockResolvedValue(mockState.session);
|
|
109
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
|
+
});
|
|
110
126
|
mockState.formSchemas = [];
|
|
111
127
|
mockState.currentA11yRoot = node('group', undefined, {
|
|
112
128
|
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
|
|
@@ -171,6 +187,42 @@ describe('batch MCP result shaping', () => {
|
|
|
171
187
|
readback: { role: 'textbox', value: 'taylor@example.com' },
|
|
172
188
|
});
|
|
173
189
|
});
|
|
190
|
+
it('resolves fieldId-only fill_fields entries from the current form schema', async () => {
|
|
191
|
+
const handler = getToolHandler('geometra_fill_fields');
|
|
192
|
+
mockState.formSchemas = [{
|
|
193
|
+
formId: 'fm:0',
|
|
194
|
+
name: 'Application',
|
|
195
|
+
fieldCount: 3,
|
|
196
|
+
requiredCount: 0,
|
|
197
|
+
invalidCount: 0,
|
|
198
|
+
fields: [
|
|
199
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name' },
|
|
200
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Preferred location', choiceType: 'select' },
|
|
201
|
+
{ id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
|
|
202
|
+
],
|
|
203
|
+
}];
|
|
204
|
+
const result = await handler({
|
|
205
|
+
fields: [
|
|
206
|
+
{ kind: 'text', fieldId: 'ff:0.0', value: 'Taylor Applicant' },
|
|
207
|
+
{ kind: 'choice', fieldId: 'ff:0.1', value: 'Berlin, Germany' },
|
|
208
|
+
{ kind: 'toggle', fieldId: 'ff:0.2', checked: true },
|
|
209
|
+
],
|
|
210
|
+
stopOnError: true,
|
|
211
|
+
failOnInvalid: false,
|
|
212
|
+
includeSteps: true,
|
|
213
|
+
detail: 'minimal',
|
|
214
|
+
});
|
|
215
|
+
const payload = JSON.parse(result.content[0].text);
|
|
216
|
+
expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Full name', 'Taylor Applicant', { exact: undefined, fieldId: 'ff:0.0' }, undefined);
|
|
217
|
+
expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Preferred location', 'Berlin, Germany', { exact: undefined, query: undefined, choiceType: 'select', fieldId: 'ff:0.1' }, undefined);
|
|
218
|
+
expect(mockState.sendSetChecked).toHaveBeenCalledWith(mockState.session, 'Share my profile for future roles', { checked: true, exact: undefined, controlType: 'checkbox' }, undefined);
|
|
219
|
+
expect(payload).toMatchObject({
|
|
220
|
+
completed: true,
|
|
221
|
+
fieldCount: 3,
|
|
222
|
+
successCount: 3,
|
|
223
|
+
errorCount: 0,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
174
226
|
it('lets run_actions omit step listings while keeping capped final validation state', async () => {
|
|
175
227
|
const handler = getToolHandler('geometra_run_actions');
|
|
176
228
|
mockState.currentA11yRoot = node('group', undefined, {
|
|
@@ -232,6 +284,112 @@ describe('batch MCP result shaping', () => {
|
|
|
232
284
|
expect(final.invalidFields.length).toBe(4);
|
|
233
285
|
expect(final.alerts.length).toBe(1);
|
|
234
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
|
+
});
|
|
235
393
|
it('returns a compact structured connect payload by default', async () => {
|
|
236
394
|
const handler = getToolHandler('geometra_connect');
|
|
237
395
|
const result = await handler({
|
|
@@ -247,6 +405,34 @@ describe('batch MCP result shaping', () => {
|
|
|
247
405
|
});
|
|
248
406
|
expect(payload).not.toHaveProperty('currentUi');
|
|
249
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
|
+
});
|
|
250
436
|
it('can inline a packed form schema into connect for the low-turn form path', async () => {
|
|
251
437
|
const handler = getToolHandler('geometra_connect');
|
|
252
438
|
mockState.formSchemas = [
|
|
@@ -316,6 +502,72 @@ describe('batch MCP result shaping', () => {
|
|
|
316
502
|
});
|
|
317
503
|
expect(payload).not.toHaveProperty('formSchema');
|
|
318
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
|
+
});
|
|
319
571
|
it('returns compact form schemas without requiring section expansion', async () => {
|
|
320
572
|
const handler = getToolHandler('geometra_form_schema');
|
|
321
573
|
mockState.formSchemas = [
|
|
@@ -633,6 +885,14 @@ describe('query and reveal tools', () => {
|
|
|
633
885
|
}),
|
|
634
886
|
],
|
|
635
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
|
+
});
|
|
636
896
|
const result = await handler({
|
|
637
897
|
role: 'button',
|
|
638
898
|
name: 'Yes',
|
|
@@ -763,6 +1023,52 @@ describe('query and reveal tools', () => {
|
|
|
763
1023
|
},
|
|
764
1024
|
});
|
|
765
1025
|
});
|
|
1026
|
+
it('auto-scales reveal steps for tall forms when maxSteps is omitted', async () => {
|
|
1027
|
+
const handler = getToolHandler('geometra_reveal');
|
|
1028
|
+
let scrollY = 0;
|
|
1029
|
+
const setTree = () => {
|
|
1030
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1031
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1032
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY },
|
|
1033
|
+
children: [
|
|
1034
|
+
node('form', 'Application', {
|
|
1035
|
+
bounds: { x: 20, y: -200 - scrollY, width: 760, height: 6200 },
|
|
1036
|
+
path: [0],
|
|
1037
|
+
children: [
|
|
1038
|
+
node('button', 'Submit application', {
|
|
1039
|
+
bounds: { x: 60, y: 5200 - scrollY, width: 180, height: 40 },
|
|
1040
|
+
path: [0, 0],
|
|
1041
|
+
}),
|
|
1042
|
+
],
|
|
1043
|
+
}),
|
|
1044
|
+
],
|
|
1045
|
+
});
|
|
1046
|
+
};
|
|
1047
|
+
setTree();
|
|
1048
|
+
mockState.sendWheel.mockImplementation(async () => {
|
|
1049
|
+
scrollY += 650;
|
|
1050
|
+
setTree();
|
|
1051
|
+
bumpMockUiRevision();
|
|
1052
|
+
return { status: 'updated', timeoutMs: 2500 };
|
|
1053
|
+
});
|
|
1054
|
+
const result = await handler({
|
|
1055
|
+
role: 'button',
|
|
1056
|
+
name: 'Submit application',
|
|
1057
|
+
fullyVisible: true,
|
|
1058
|
+
timeoutMs: 2500,
|
|
1059
|
+
});
|
|
1060
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1061
|
+
expect(mockState.sendWheel).toHaveBeenCalledTimes(7);
|
|
1062
|
+
expect(payload).toMatchObject({
|
|
1063
|
+
revealed: true,
|
|
1064
|
+
attempts: 7,
|
|
1065
|
+
target: {
|
|
1066
|
+
role: 'button',
|
|
1067
|
+
name: 'Submit application',
|
|
1068
|
+
visibility: { fullyVisible: true },
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
766
1072
|
it('clicks an offscreen semantic target by revealing it first', async () => {
|
|
767
1073
|
const handler = getToolHandler('geometra_click');
|
|
768
1074
|
mockState.currentA11yRoot = node('group', undefined, {
|
|
@@ -855,6 +1161,41 @@ describe('query and reveal tools', () => {
|
|
|
855
1161
|
expect(result.content[0].text).toContain('Post-click condition satisfied after');
|
|
856
1162
|
expect(result.content[0].text).toContain('1 matching node(s).');
|
|
857
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
|
+
});
|
|
858
1199
|
it('lets run_actions click a semantic target without manual coordinates', async () => {
|
|
859
1200
|
const handler = getToolHandler('geometra_run_actions');
|
|
860
1201
|
mockState.currentA11yRoot = node('group', undefined, {
|
|
@@ -915,6 +1256,7 @@ describe('query and reveal tools', () => {
|
|
|
915
1256
|
index: 0,
|
|
916
1257
|
type: 'click',
|
|
917
1258
|
ok: true,
|
|
1259
|
+
elapsedMs: expect.any(Number),
|
|
918
1260
|
at: { x: 150, y: 340 },
|
|
919
1261
|
revealSteps: 1,
|
|
920
1262
|
target: { id: 'n:0.0', role: 'button', name: 'Submit application' },
|
|
@@ -974,6 +1316,7 @@ describe('query and reveal tools', () => {
|
|
|
974
1316
|
index: 0,
|
|
975
1317
|
type: 'click',
|
|
976
1318
|
ok: true,
|
|
1319
|
+
elapsedMs: expect.any(Number),
|
|
977
1320
|
postWait: {
|
|
978
1321
|
present: true,
|
|
979
1322
|
matchCount: 1,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildA11yTree, buildCompactUiIndex, buildFormSchemas, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
2
|
+
import { buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildFormSchemas, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
3
3
|
function node(role, name, bounds, options) {
|
|
4
4
|
return {
|
|
5
5
|
role,
|
|
@@ -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: [
|
|
@@ -375,6 +423,78 @@ describe('buildFormSchemas', () => {
|
|
|
375
423
|
});
|
|
376
424
|
});
|
|
377
425
|
});
|
|
426
|
+
describe('buildFormRequiredSnapshot', () => {
|
|
427
|
+
it('keeps offscreen required fields with bounds, visibility, and scroll hints', () => {
|
|
428
|
+
const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
|
|
429
|
+
children: [
|
|
430
|
+
node('form', 'Application', { x: 20, y: -160, width: 760, height: 2200 }, {
|
|
431
|
+
path: [0],
|
|
432
|
+
children: [
|
|
433
|
+
node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
|
|
434
|
+
path: [0, 0],
|
|
435
|
+
state: { required: true },
|
|
436
|
+
}),
|
|
437
|
+
node('combobox', 'Preferred location', { x: 48, y: 940, width: 320, height: 36 }, {
|
|
438
|
+
path: [0, 1],
|
|
439
|
+
state: { required: true },
|
|
440
|
+
meta: { controlTag: 'select' },
|
|
441
|
+
}),
|
|
442
|
+
node('group', undefined, { x: 40, y: 1260, width: 520, height: 96 }, {
|
|
443
|
+
path: [0, 2],
|
|
444
|
+
children: [
|
|
445
|
+
node('text', 'Will you require sponsorship?', { x: 48, y: 1260, width: 320, height: 24 }, {
|
|
446
|
+
path: [0, 2, 0],
|
|
447
|
+
}),
|
|
448
|
+
node('button', 'Yes', { x: 48, y: 1300, width: 88, height: 40 }, {
|
|
449
|
+
path: [0, 2, 1],
|
|
450
|
+
focusable: true,
|
|
451
|
+
state: { required: true },
|
|
452
|
+
}),
|
|
453
|
+
node('button', 'No', { x: 148, y: 1300, width: 88, height: 40 }, {
|
|
454
|
+
path: [0, 2, 2],
|
|
455
|
+
focusable: true,
|
|
456
|
+
}),
|
|
457
|
+
],
|
|
458
|
+
}),
|
|
459
|
+
],
|
|
460
|
+
}),
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
const snapshot = buildFormRequiredSnapshot(tree, { includeOptions: true });
|
|
464
|
+
expect(snapshot).toHaveLength(1);
|
|
465
|
+
expect(snapshot[0]).toMatchObject({
|
|
466
|
+
formId: 'fm:0',
|
|
467
|
+
name: 'Application',
|
|
468
|
+
requiredCount: 3,
|
|
469
|
+
fields: [
|
|
470
|
+
{
|
|
471
|
+
id: 'ff:0.0',
|
|
472
|
+
label: 'Full name',
|
|
473
|
+
visibility: { intersectsViewport: true, fullyVisible: true },
|
|
474
|
+
scrollHint: { status: 'visible' },
|
|
475
|
+
bounds: { x: 48, y: 120, width: 320, height: 36 },
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
id: 'ff:0.1',
|
|
479
|
+
label: 'Preferred location',
|
|
480
|
+
choiceType: 'select',
|
|
481
|
+
visibility: { intersectsViewport: false, offscreenBelow: true },
|
|
482
|
+
scrollHint: { status: 'offscreen' },
|
|
483
|
+
bounds: { x: 48, y: 940, width: 320, height: 36 },
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
id: 'ff:0.2',
|
|
487
|
+
label: 'Will you require sponsorship?',
|
|
488
|
+
choiceType: 'group',
|
|
489
|
+
visibility: { intersectsViewport: false, offscreenBelow: true },
|
|
490
|
+
scrollHint: { status: 'offscreen' },
|
|
491
|
+
bounds: { x: 40, y: 1260, width: 520, height: 96 },
|
|
492
|
+
options: ['Yes', 'No'],
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
});
|
|
378
498
|
describe('buildUiDelta', () => {
|
|
379
499
|
it('captures opened dialogs, state changes, and list count changes', () => {
|
|
380
500
|
const before = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
|
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;
|
package/dist/proxy-spawn.js
CHANGED
|
@@ -138,6 +138,7 @@ export async function startEmbeddedGeometraProxy(opts) {
|
|
|
138
138
|
height: opts.height,
|
|
139
139
|
headed: opts.headless !== true,
|
|
140
140
|
slowMo: opts.slowMo,
|
|
141
|
+
eagerInitialExtract: opts.eagerInitialExtract,
|
|
141
142
|
});
|
|
142
143
|
return { runtime, wsUrl: runtime.wsUrl };
|
|
143
144
|
}
|
|
@@ -189,6 +190,8 @@ export function spawnGeometraProxy(opts) {
|
|
|
189
190
|
args.push('--headless');
|
|
190
191
|
else if (opts.headless === false)
|
|
191
192
|
args.push('--headed');
|
|
193
|
+
if (opts.eagerInitialExtract === false)
|
|
194
|
+
args.push('--lazy-initial-extract');
|
|
192
195
|
return new Promise((resolve, reject) => {
|
|
193
196
|
const child = spawn(process.execPath, args, {
|
|
194
197
|
stdio: ['ignore', 'pipe', 'pipe'],
|