@geometra/mcp 1.49.0 → 1.53.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/dist/__tests__/ats-integration.test.js +1 -0
- package/dist/__tests__/connect-utils.test.js +82 -3
- package/dist/__tests__/proxy-session-actions.test.js +66 -0
- package/dist/__tests__/server-batch-results.test.js +273 -1
- package/dist/__tests__/server-session-resolution.test.d.ts +1 -0
- package/dist/__tests__/server-session-resolution.test.js +88 -0
- package/dist/connect-utils.d.ts +2 -0
- package/dist/connect-utils.js +5 -3
- package/dist/server.js +295 -7
- package/dist/session.d.ts +6 -0
- package/dist/session.js +158 -25
- package/package.json +1 -1
|
@@ -70,6 +70,7 @@ vi.mock('../session.js', () => ({
|
|
|
70
70
|
connectThroughProxy: mockState.connectThroughProxy,
|
|
71
71
|
prewarmProxy: mockState.prewarmProxy,
|
|
72
72
|
disconnect: vi.fn(),
|
|
73
|
+
pruneDisconnectedSessions: vi.fn(() => []),
|
|
73
74
|
getSession: vi.fn(() => mockState.session),
|
|
74
75
|
resolveSession: vi.fn((id) => ({ kind: 'ok', session: mockState.session, ...(id ? { id } : {}) })),
|
|
75
76
|
listSessions: vi.fn(() => [{ id: 's1', url: 'https://jobs.example.com/application' }]),
|
|
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { describe, expect, it } from 'vitest';
|
|
6
|
-
import { formatConnectFailureMessage, normalizeConnectTarget } from '../connect-utils.js';
|
|
6
|
+
import { CONNECT_TARGET_EXACTLY_ONE_ERROR, formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget, } from '../connect-utils.js';
|
|
7
7
|
import { formatProxyStartupFailure, parseProxyReadySignalLine, resolveProxyScriptPath, resolveProxyScriptPathWith, } from '../proxy-spawn.js';
|
|
8
8
|
describe('normalizeConnectTarget', () => {
|
|
9
9
|
it('accepts explicit pageUrl for http(s) pages', () => {
|
|
@@ -46,23 +46,102 @@ describe('normalizeConnectTarget', () => {
|
|
|
46
46
|
},
|
|
47
47
|
});
|
|
48
48
|
});
|
|
49
|
+
it('accepts wss url input for already-running peers (TLS WebSocket)', () => {
|
|
50
|
+
const result = normalizeConnectTarget({ url: 'wss://example.com/socket' });
|
|
51
|
+
expect(result).toEqual({
|
|
52
|
+
ok: true,
|
|
53
|
+
value: {
|
|
54
|
+
kind: 'ws',
|
|
55
|
+
wsUrl: 'wss://example.com/socket',
|
|
56
|
+
autoCoercedFromUrl: false,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
});
|
|
49
60
|
it('rejects ambiguous and empty connect inputs', () => {
|
|
50
61
|
expect(normalizeConnectTarget({})).toEqual({
|
|
51
62
|
ok: false,
|
|
52
|
-
error:
|
|
63
|
+
error: CONNECT_TARGET_EXACTLY_ONE_ERROR,
|
|
53
64
|
});
|
|
54
65
|
expect(normalizeConnectTarget({ url: 'ws://127.0.0.1:3100', pageUrl: 'https://example.com' })).toEqual({
|
|
55
66
|
ok: false,
|
|
56
|
-
error:
|
|
67
|
+
error: CONNECT_TARGET_EXACTLY_ONE_ERROR,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
it('rejects invalid pageUrl strings (URL parser failure)', () => {
|
|
71
|
+
const result = normalizeConnectTarget({ pageUrl: 'https://exam ple.com' });
|
|
72
|
+
expect(result.ok).toBe(false);
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
expect(result.error.startsWith('Invalid pageUrl:')).toBe(true);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it('rejects non-http(s) url when using pageUrl (explicit)', () => {
|
|
78
|
+
expect(normalizeConnectTarget({ pageUrl: 'file:///tmp/x.html' })).toEqual({
|
|
79
|
+
ok: false,
|
|
80
|
+
error: 'pageUrl must use http:// or https:// (received file:)',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
it('rejects invalid url strings for the url field', () => {
|
|
84
|
+
const result = normalizeConnectTarget({ url: ':::not-a-url' });
|
|
85
|
+
expect(result.ok).toBe(false);
|
|
86
|
+
if (!result.ok) {
|
|
87
|
+
expect(result.error).toContain('Invalid url:');
|
|
88
|
+
expect(result.error).toContain('ws://');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
it('rejects unsupported protocols on the url field (neither http(s) nor ws(s))', () => {
|
|
92
|
+
expect(normalizeConnectTarget({ url: 'ftp://files.example.com/pub' })).toEqual({
|
|
93
|
+
ok: false,
|
|
94
|
+
error: 'Unsupported url protocol ftp:. Use ws://... for an already-running Geometra server, or http:// / https:// for webpages.',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
it('trims whitespace-only inputs to empty (same as omitting url/pageUrl)', () => {
|
|
98
|
+
expect(normalizeConnectTarget({ url: ' ', pageUrl: undefined })).toEqual({
|
|
99
|
+
ok: false,
|
|
100
|
+
error: CONNECT_TARGET_EXACTLY_ONE_ERROR,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
it('trims whitespace-only pageUrl to empty (symmetric with url-only whitespace)', () => {
|
|
104
|
+
expect(normalizeConnectTarget({ pageUrl: ' ' })).toEqual({
|
|
105
|
+
ok: false,
|
|
106
|
+
error: CONNECT_TARGET_EXACTLY_ONE_ERROR,
|
|
57
107
|
});
|
|
58
108
|
});
|
|
59
109
|
});
|
|
110
|
+
describe('isHttpUrl', () => {
|
|
111
|
+
it('accepts http and https URLs', () => {
|
|
112
|
+
expect(isHttpUrl('https://example.com/path')).toBe(true);
|
|
113
|
+
expect(isHttpUrl('http://localhost:8080/')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
it('accepts IPv6 literal hosts (URL parser canonical form)', () => {
|
|
116
|
+
expect(isHttpUrl('https://[::1]:8443/')).toBe(true);
|
|
117
|
+
expect(isHttpUrl('http://[2001:db8::1]/')).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
it('rejects ws(s), file, and other schemes', () => {
|
|
120
|
+
expect(isHttpUrl('ws://127.0.0.1:3100')).toBe(false);
|
|
121
|
+
expect(isHttpUrl('wss://example.com')).toBe(false);
|
|
122
|
+
expect(isHttpUrl('file:///tmp/x')).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
it('rejects malformed strings', () => {
|
|
125
|
+
expect(isHttpUrl('not a url')).toBe(false);
|
|
126
|
+
expect(isHttpUrl('')).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
60
129
|
describe('formatConnectFailureMessage', () => {
|
|
61
130
|
it('adds a targeted hint when ws connect fails for a normal webpage flow', () => {
|
|
62
131
|
const message = formatConnectFailureMessage(new Error('WebSocket error connecting to ws://localhost:3100: connect ECONNREFUSED'), { kind: 'ws', wsUrl: 'ws://localhost:3100', autoCoercedFromUrl: false });
|
|
63
132
|
expect(message).toContain('ECONNREFUSED');
|
|
64
133
|
expect(message).toContain('pageUrl: "https://…"');
|
|
65
134
|
});
|
|
135
|
+
it('adds the same hint when ws connect fails with DNS resolution errors (wrong host or offline resolver)', () => {
|
|
136
|
+
const message = formatConnectFailureMessage(new Error('getaddrinfo ENOTFOUND bad.example.com'), { kind: 'ws', wsUrl: 'ws://bad.example.com:3100', autoCoercedFromUrl: false });
|
|
137
|
+
expect(message).toContain('ENOTFOUND');
|
|
138
|
+
expect(message).toContain('pageUrl:');
|
|
139
|
+
});
|
|
140
|
+
it('adds an install hint when the proxy package cannot be resolved', () => {
|
|
141
|
+
const message = formatConnectFailureMessage(new Error('Could not resolve @geometra/proxy from mcp'), { kind: 'proxy', pageUrl: 'https://example.com', autoCoercedFromUrl: false });
|
|
142
|
+
expect(message).toContain('Could not resolve @geometra/proxy');
|
|
143
|
+
expect(message).toContain('@geometra/proxy');
|
|
144
|
+
});
|
|
66
145
|
});
|
|
67
146
|
describe('proxy ready helpers', () => {
|
|
68
147
|
it('resolves the bundled proxy CLI entry in the source tree', () => {
|
|
@@ -287,4 +287,70 @@ describe('proxy-backed MCP actions', () => {
|
|
|
287
287
|
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
288
288
|
}
|
|
289
289
|
});
|
|
290
|
+
it('reconnects once when an action is sent on a closed socket', async () => {
|
|
291
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
292
|
+
let connectionCount = 0;
|
|
293
|
+
wss.on('connection', ws => {
|
|
294
|
+
connectionCount += 1;
|
|
295
|
+
ws.on('message', raw => {
|
|
296
|
+
const msg = JSON.parse(String(raw));
|
|
297
|
+
if (msg.type === 'resize') {
|
|
298
|
+
ws.send(JSON.stringify({
|
|
299
|
+
type: 'frame',
|
|
300
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
301
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
|
|
302
|
+
}));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (msg.type === 'event') {
|
|
306
|
+
ws.send(JSON.stringify({
|
|
307
|
+
type: 'frame',
|
|
308
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
309
|
+
tree: {
|
|
310
|
+
kind: 'box',
|
|
311
|
+
props: {},
|
|
312
|
+
semantic: { tag: 'body', role: 'group', ariaLabel: 'Reconnected' },
|
|
313
|
+
children: [],
|
|
314
|
+
},
|
|
315
|
+
}));
|
|
316
|
+
ws.send(JSON.stringify({
|
|
317
|
+
type: 'ack',
|
|
318
|
+
requestId: msg.requestId,
|
|
319
|
+
result: { ok: true },
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
const port = await new Promise((resolve, reject) => {
|
|
325
|
+
wss.once('listening', () => {
|
|
326
|
+
const address = wss.address();
|
|
327
|
+
if (typeof address === 'object' && address)
|
|
328
|
+
resolve(address.port);
|
|
329
|
+
else
|
|
330
|
+
reject(new Error('Failed to resolve ephemeral WebSocket port'));
|
|
331
|
+
});
|
|
332
|
+
wss.once('error', reject);
|
|
333
|
+
});
|
|
334
|
+
try {
|
|
335
|
+
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
336
|
+
await new Promise(resolve => {
|
|
337
|
+
if (session.ws.readyState === session.ws.CLOSED) {
|
|
338
|
+
resolve();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
session.ws.once('close', () => resolve());
|
|
342
|
+
session.ws.close();
|
|
343
|
+
});
|
|
344
|
+
await expect(sendClick(session, 5, 5, 150)).resolves.toMatchObject({
|
|
345
|
+
status: 'updated',
|
|
346
|
+
timeoutMs: 150,
|
|
347
|
+
result: { ok: true },
|
|
348
|
+
});
|
|
349
|
+
expect(connectionCount).toBeGreaterThanOrEqual(2);
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
disconnect();
|
|
353
|
+
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
354
|
+
}
|
|
355
|
+
});
|
|
290
356
|
});
|
|
@@ -47,6 +47,7 @@ const mockState = vi.hoisted(() => ({
|
|
|
47
47
|
sendSetChecked: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
48
48
|
sendWheel: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
49
49
|
waitForUiCondition: vi.fn(async (_session, _check, _timeoutMs) => true),
|
|
50
|
+
expandPageSection: vi.fn((_root, _id, _opts) => null),
|
|
50
51
|
}));
|
|
51
52
|
function resetMockSessionCaches() {
|
|
52
53
|
mockState.session.updateRevision = 1;
|
|
@@ -66,6 +67,7 @@ vi.mock('../session.js', () => ({
|
|
|
66
67
|
connectThroughProxy: mockState.connectThroughProxy,
|
|
67
68
|
prewarmProxy: mockState.prewarmProxy,
|
|
68
69
|
disconnect: vi.fn(),
|
|
70
|
+
pruneDisconnectedSessions: vi.fn(() => []),
|
|
69
71
|
getSession: vi.fn(() => mockState.session),
|
|
70
72
|
resolveSession: vi.fn((id) => ({ kind: 'ok', session: mockState.session, ...(id ? { id } : {}) })),
|
|
71
73
|
listSessions: vi.fn(() => [{ id: 's1', url: 'https://jobs.example.com/application' }]),
|
|
@@ -96,7 +98,7 @@ vi.mock('../session.js', () => ({
|
|
|
96
98
|
})),
|
|
97
99
|
buildFormSchemas: vi.fn(() => mockState.formSchemas),
|
|
98
100
|
buildFormRequiredSnapshot: vi.fn(() => []),
|
|
99
|
-
expandPageSection:
|
|
101
|
+
expandPageSection: mockState.expandPageSection,
|
|
100
102
|
buildUiDelta: vi.fn(() => ({})),
|
|
101
103
|
hasUiDelta: vi.fn(() => false),
|
|
102
104
|
nodeIdForPath: vi.fn((path) => `n:${path.length > 0 ? path.join('.') : 'root'}`),
|
|
@@ -365,6 +367,130 @@ describe('batch MCP result shaping', () => {
|
|
|
365
367
|
expect(payload).not.toHaveProperty('steps');
|
|
366
368
|
expect(payload).not.toHaveProperty('stepCount');
|
|
367
369
|
});
|
|
370
|
+
it('attaches verifyFills readback results to run_actions fill_fields step', async () => {
|
|
371
|
+
const handler = getToolHandler('geometra_run_actions');
|
|
372
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
373
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
374
|
+
children: [
|
|
375
|
+
node('textbox', 'Full name', { value: 'Taylor Applicant', path: [0] }),
|
|
376
|
+
node('textbox', 'Phone', { value: '(929) 608-1737', path: [1] }),
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
const result = await handler({
|
|
380
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
381
|
+
headless: true,
|
|
382
|
+
actions: [
|
|
383
|
+
{
|
|
384
|
+
type: 'fill_fields',
|
|
385
|
+
verifyFills: true,
|
|
386
|
+
fields: [
|
|
387
|
+
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
388
|
+
{ kind: 'text', fieldLabel: 'Phone', value: '+1-929-608-1737' },
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
stopOnError: true,
|
|
393
|
+
includeSteps: true,
|
|
394
|
+
detail: 'terse',
|
|
395
|
+
});
|
|
396
|
+
const payload = JSON.parse(result.content[0].text);
|
|
397
|
+
const steps = payload.steps;
|
|
398
|
+
expect(steps).toHaveLength(1);
|
|
399
|
+
expect(steps[0]).toMatchObject({
|
|
400
|
+
type: 'fill_fields',
|
|
401
|
+
ok: true,
|
|
402
|
+
fieldCount: 2,
|
|
403
|
+
verification: { verified: 2, mismatches: [] },
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
it('runs expand_section inside run_actions and returns the section detail inline', async () => {
|
|
407
|
+
const handler = getToolHandler('geometra_run_actions');
|
|
408
|
+
const fakeDetail = {
|
|
409
|
+
id: 'fm:1.0',
|
|
410
|
+
kind: 'form',
|
|
411
|
+
name: 'Application',
|
|
412
|
+
fieldCount: 3,
|
|
413
|
+
fields: [{ id: 'f1', label: 'Full name', required: true }],
|
|
414
|
+
};
|
|
415
|
+
mockState.expandPageSection.mockReturnValueOnce(fakeDetail);
|
|
416
|
+
const result = await handler({
|
|
417
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
418
|
+
headless: true,
|
|
419
|
+
actions: [
|
|
420
|
+
{
|
|
421
|
+
type: 'expand_section',
|
|
422
|
+
id: 'fm:1.0',
|
|
423
|
+
maxFields: 10,
|
|
424
|
+
onlyRequiredFields: true,
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
includeSteps: true,
|
|
428
|
+
detail: 'terse',
|
|
429
|
+
});
|
|
430
|
+
expect(mockState.expandPageSection).toHaveBeenCalledWith(mockState.currentA11yRoot, 'fm:1.0', expect.objectContaining({ maxFields: 10, onlyRequiredFields: true }));
|
|
431
|
+
const payload = JSON.parse(result.content[0].text);
|
|
432
|
+
const steps = payload.steps;
|
|
433
|
+
expect(steps).toHaveLength(1);
|
|
434
|
+
expect(steps[0]).toMatchObject({
|
|
435
|
+
type: 'expand_section',
|
|
436
|
+
ok: true,
|
|
437
|
+
id: 'fm:1.0',
|
|
438
|
+
detail: fakeDetail,
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
it('surfaces an error when expand_section id does not match a section', async () => {
|
|
442
|
+
const handler = getToolHandler('geometra_run_actions');
|
|
443
|
+
mockState.expandPageSection.mockReturnValueOnce(null);
|
|
444
|
+
const result = await handler({
|
|
445
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
446
|
+
headless: true,
|
|
447
|
+
actions: [{ type: 'expand_section', id: 'fm:9.9' }],
|
|
448
|
+
includeSteps: true,
|
|
449
|
+
stopOnError: false,
|
|
450
|
+
detail: 'terse',
|
|
451
|
+
});
|
|
452
|
+
const payload = JSON.parse(result.content[0].text);
|
|
453
|
+
const steps = payload.steps;
|
|
454
|
+
expect(steps[0]).toMatchObject({
|
|
455
|
+
type: 'expand_section',
|
|
456
|
+
ok: false,
|
|
457
|
+
error: expect.stringContaining('fm:9.9'),
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
it('flags mismatched fields in run_actions verifyFills output', async () => {
|
|
461
|
+
const handler = getToolHandler('geometra_run_actions');
|
|
462
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
463
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
464
|
+
children: [
|
|
465
|
+
node('textbox', 'Full name', { value: 'Unexpected Name', path: [0] }),
|
|
466
|
+
],
|
|
467
|
+
});
|
|
468
|
+
const result = await handler({
|
|
469
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
470
|
+
headless: true,
|
|
471
|
+
actions: [
|
|
472
|
+
{
|
|
473
|
+
type: 'fill_fields',
|
|
474
|
+
verifyFills: true,
|
|
475
|
+
fields: [
|
|
476
|
+
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
477
|
+
],
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
includeSteps: true,
|
|
481
|
+
detail: 'terse',
|
|
482
|
+
});
|
|
483
|
+
const payload = JSON.parse(result.content[0].text);
|
|
484
|
+
const steps = payload.steps;
|
|
485
|
+
const verification = steps[0].verification;
|
|
486
|
+
expect(verification.verified).toBe(0);
|
|
487
|
+
expect(verification.mismatches).toHaveLength(1);
|
|
488
|
+
expect(verification.mismatches[0]).toMatchObject({
|
|
489
|
+
fieldLabel: 'Full name',
|
|
490
|
+
expected: 'Taylor Applicant',
|
|
491
|
+
actual: 'Unexpected Name',
|
|
492
|
+
});
|
|
493
|
+
});
|
|
368
494
|
it('finds repeated actions by itemText in terse mode', async () => {
|
|
369
495
|
const handler = getToolHandler('geometra_find_action');
|
|
370
496
|
mockState.currentA11yRoot = node('group', undefined, {
|
|
@@ -856,6 +982,152 @@ describe('batch MCP result shaping', () => {
|
|
|
856
982
|
});
|
|
857
983
|
});
|
|
858
984
|
});
|
|
985
|
+
describe('submit_form tool', () => {
|
|
986
|
+
beforeEach(() => {
|
|
987
|
+
vi.clearAllMocks();
|
|
988
|
+
resetMockSessionCaches();
|
|
989
|
+
mockState.formSchemas = [];
|
|
990
|
+
});
|
|
991
|
+
it('combines fill + submit-click + post-submit wait in one call', async () => {
|
|
992
|
+
const handler = getToolHandler('geometra_submit_form');
|
|
993
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
994
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
995
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
996
|
+
children: [
|
|
997
|
+
node('textbox', 'Full name', { value: '', path: [0] }),
|
|
998
|
+
node('textbox', 'Email', { value: '', path: [1] }),
|
|
999
|
+
node('button', 'Submit application', {
|
|
1000
|
+
bounds: { x: 60, y: 480, width: 180, height: 40 },
|
|
1001
|
+
path: [2],
|
|
1002
|
+
}),
|
|
1003
|
+
],
|
|
1004
|
+
});
|
|
1005
|
+
mockState.formSchemas = [{
|
|
1006
|
+
formId: 'fm:0',
|
|
1007
|
+
name: 'Application',
|
|
1008
|
+
fieldCount: 2,
|
|
1009
|
+
requiredCount: 2,
|
|
1010
|
+
invalidCount: 2,
|
|
1011
|
+
fields: [
|
|
1012
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name' },
|
|
1013
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Email' },
|
|
1014
|
+
],
|
|
1015
|
+
}];
|
|
1016
|
+
mockState.sendFillFields.mockImplementationOnce(async () => ({
|
|
1017
|
+
status: 'acknowledged',
|
|
1018
|
+
timeoutMs: 6000,
|
|
1019
|
+
result: { invalidCount: 0, alertCount: 0, dialogCount: 0, busyCount: 0, pageUrl: 'https://jobs.example.com/application' },
|
|
1020
|
+
}));
|
|
1021
|
+
mockState.sendClick.mockImplementationOnce(async () => {
|
|
1022
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1023
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1024
|
+
meta: { pageUrl: 'https://jobs.example.com/confirm', scrollX: 0, scrollY: 0 },
|
|
1025
|
+
children: [
|
|
1026
|
+
node('dialog', 'Application submitted', {
|
|
1027
|
+
bounds: { x: 240, y: 140, width: 420, height: 260 },
|
|
1028
|
+
path: [0],
|
|
1029
|
+
}),
|
|
1030
|
+
],
|
|
1031
|
+
});
|
|
1032
|
+
bumpMockUiRevision();
|
|
1033
|
+
return { status: 'updated', timeoutMs: 2000 };
|
|
1034
|
+
});
|
|
1035
|
+
const result = await handler({
|
|
1036
|
+
valuesByLabel: { 'Full name': 'Taylor Applicant', Email: 'taylor@example.com' },
|
|
1037
|
+
submit: { role: 'button', name: 'Submit application' },
|
|
1038
|
+
waitFor: { role: 'dialog', name: 'Application submitted', timeoutMs: 5000 },
|
|
1039
|
+
detail: 'minimal',
|
|
1040
|
+
});
|
|
1041
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1042
|
+
expect(payload).toMatchObject({
|
|
1043
|
+
completed: true,
|
|
1044
|
+
fill: { fieldCount: 2, formId: 'fm:0' },
|
|
1045
|
+
submit: { target: { role: 'button', name: 'Submit application' } },
|
|
1046
|
+
waitFor: { present: true, matchCount: 1 },
|
|
1047
|
+
navigated: true,
|
|
1048
|
+
afterUrl: 'https://jobs.example.com/confirm',
|
|
1049
|
+
});
|
|
1050
|
+
expect(mockState.sendFillFields).toHaveBeenCalledTimes(1);
|
|
1051
|
+
expect(mockState.sendClick).toHaveBeenCalledTimes(1);
|
|
1052
|
+
});
|
|
1053
|
+
it('rejects missing values when skipFill is false', async () => {
|
|
1054
|
+
const handler = getToolHandler('geometra_submit_form');
|
|
1055
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1056
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1057
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1058
|
+
children: [
|
|
1059
|
+
node('button', 'Submit', { bounds: { x: 60, y: 480, width: 100, height: 40 }, path: [0] }),
|
|
1060
|
+
],
|
|
1061
|
+
});
|
|
1062
|
+
const result = await handler({ detail: 'minimal' });
|
|
1063
|
+
expect(result.content[0].text).toContain('Provide at least one value');
|
|
1064
|
+
});
|
|
1065
|
+
it('skipFill: true goes straight to submit + wait', async () => {
|
|
1066
|
+
const handler = getToolHandler('geometra_submit_form');
|
|
1067
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1068
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1069
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1070
|
+
children: [
|
|
1071
|
+
node('button', 'Submit', { bounds: { x: 60, y: 480, width: 100, height: 40 }, path: [0] }),
|
|
1072
|
+
],
|
|
1073
|
+
});
|
|
1074
|
+
mockState.sendClick.mockImplementationOnce(async () => {
|
|
1075
|
+
bumpMockUiRevision();
|
|
1076
|
+
return { status: 'updated', timeoutMs: 2000 };
|
|
1077
|
+
});
|
|
1078
|
+
const result = await handler({
|
|
1079
|
+
skipFill: true,
|
|
1080
|
+
submit: { role: 'button', name: 'Submit' },
|
|
1081
|
+
detail: 'minimal',
|
|
1082
|
+
});
|
|
1083
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1084
|
+
expect(payload).toMatchObject({ completed: true });
|
|
1085
|
+
expect(payload).not.toHaveProperty('fill');
|
|
1086
|
+
expect(mockState.sendFillFields).not.toHaveBeenCalled();
|
|
1087
|
+
expect(mockState.sendClick).toHaveBeenCalledTimes(1);
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
describe('click transparent fallback', () => {
|
|
1091
|
+
beforeEach(() => {
|
|
1092
|
+
vi.clearAllMocks();
|
|
1093
|
+
resetMockSessionCaches();
|
|
1094
|
+
});
|
|
1095
|
+
it('surfaces fallback.used when relaxed-visibility lets an offscreen submit resolve', async () => {
|
|
1096
|
+
const handler = getToolHandler('geometra_click');
|
|
1097
|
+
// First tree: target exists but is offscreen below the viewport, so a
|
|
1098
|
+
// fullyVisible requirement cannot be satisfied before the reveal budget runs out.
|
|
1099
|
+
// The relaxed-visibility fallback drops the fullyVisible requirement and tries
|
|
1100
|
+
// once more with a larger reveal budget.
|
|
1101
|
+
const offscreenTree = node('group', undefined, {
|
|
1102
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1103
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1104
|
+
children: [
|
|
1105
|
+
node('button', 'Submit', {
|
|
1106
|
+
// Starts fully offscreen-below and wheel stubs don't move it in tests,
|
|
1107
|
+
// so the fullyVisible attempt will fail. Relaxed-visibility sees it
|
|
1108
|
+
// intersect enough to count as revealed.
|
|
1109
|
+
bounds: { x: 60, y: 780, width: 180, height: 60 },
|
|
1110
|
+
path: [0],
|
|
1111
|
+
}),
|
|
1112
|
+
],
|
|
1113
|
+
});
|
|
1114
|
+
mockState.currentA11yRoot = offscreenTree;
|
|
1115
|
+
mockState.sendClick.mockResolvedValueOnce({ status: 'updated', timeoutMs: 2000 });
|
|
1116
|
+
const result = await handler({
|
|
1117
|
+
role: 'button',
|
|
1118
|
+
name: 'Submit',
|
|
1119
|
+
fullyVisible: true,
|
|
1120
|
+
maxRevealSteps: 1,
|
|
1121
|
+
revealTimeoutMs: 100,
|
|
1122
|
+
detail: 'terse',
|
|
1123
|
+
});
|
|
1124
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1125
|
+
expect(payload).toMatchObject({
|
|
1126
|
+
target: { role: 'button', name: 'Submit' },
|
|
1127
|
+
fallback: { used: true, reason: 'relaxed-visibility' },
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
859
1131
|
describe('query and reveal tools', () => {
|
|
860
1132
|
beforeEach(() => {
|
|
861
1133
|
vi.clearAllMocks();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const mockState = vi.hoisted(() => ({
|
|
3
|
+
pruneDisconnectedSessions: vi.fn(() => []),
|
|
4
|
+
resolveSession: vi.fn(() => ({ kind: 'none' })),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('../session.js', () => ({
|
|
7
|
+
connect: vi.fn(),
|
|
8
|
+
connectThroughProxy: vi.fn(),
|
|
9
|
+
disconnect: vi.fn(),
|
|
10
|
+
pruneDisconnectedSessions: mockState.pruneDisconnectedSessions,
|
|
11
|
+
resolveSession: mockState.resolveSession,
|
|
12
|
+
listSessions: vi.fn(() => []),
|
|
13
|
+
getDefaultSessionId: vi.fn(() => null),
|
|
14
|
+
prewarmProxy: vi.fn(),
|
|
15
|
+
sendClick: vi.fn(),
|
|
16
|
+
sendFillFields: vi.fn(),
|
|
17
|
+
sendFillOtp: vi.fn(),
|
|
18
|
+
sendType: vi.fn(),
|
|
19
|
+
sendKey: vi.fn(),
|
|
20
|
+
sendFileUpload: vi.fn(),
|
|
21
|
+
sendFieldText: vi.fn(),
|
|
22
|
+
sendFieldChoice: vi.fn(),
|
|
23
|
+
sendListboxPick: vi.fn(),
|
|
24
|
+
sendSelectOption: vi.fn(),
|
|
25
|
+
sendSetChecked: vi.fn(),
|
|
26
|
+
sendWheel: vi.fn(),
|
|
27
|
+
sendScreenshot: vi.fn(),
|
|
28
|
+
sendPdfGenerate: vi.fn(),
|
|
29
|
+
buildA11yTree: vi.fn(),
|
|
30
|
+
buildCompactUiIndex: vi.fn(() => ({ nodes: [], context: {} })),
|
|
31
|
+
buildFormRequiredSnapshot: vi.fn(() => []),
|
|
32
|
+
buildPageModel: vi.fn(),
|
|
33
|
+
buildFormSchemas: vi.fn(() => []),
|
|
34
|
+
expandPageSection: vi.fn(),
|
|
35
|
+
buildUiDelta: vi.fn(() => ({})),
|
|
36
|
+
hasUiDelta: vi.fn(() => false),
|
|
37
|
+
nodeIdForPath: vi.fn(),
|
|
38
|
+
nodeContextForNode: vi.fn(),
|
|
39
|
+
parseSectionId: vi.fn(),
|
|
40
|
+
findNodeByPath: vi.fn(),
|
|
41
|
+
summarizeCompactIndex: vi.fn(() => ''),
|
|
42
|
+
summarizePageModel: vi.fn(() => ''),
|
|
43
|
+
summarizeUiDelta: vi.fn(() => ''),
|
|
44
|
+
waitForUiCondition: vi.fn(),
|
|
45
|
+
}));
|
|
46
|
+
const { createServer } = await import('../server.js');
|
|
47
|
+
function getToolHandler(name) {
|
|
48
|
+
const server = createServer();
|
|
49
|
+
return server._registeredTools[name].handler;
|
|
50
|
+
}
|
|
51
|
+
describe('server session resolution', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
mockState.pruneDisconnectedSessions.mockReturnValue([]);
|
|
55
|
+
mockState.resolveSession.mockReturnValue({ kind: 'none' });
|
|
56
|
+
});
|
|
57
|
+
it('prunes disconnected sessions before resolving explicit session ids', async () => {
|
|
58
|
+
const handler = getToolHandler('geometra_query');
|
|
59
|
+
mockState.pruneDisconnectedSessions.mockReturnValue(['s7']);
|
|
60
|
+
mockState.resolveSession.mockReturnValue({
|
|
61
|
+
kind: 'not_found',
|
|
62
|
+
id: 's7',
|
|
63
|
+
activeIds: [],
|
|
64
|
+
});
|
|
65
|
+
const result = await handler({ sessionId: 's7', role: 'button' });
|
|
66
|
+
expect(result.isError).toBe(true);
|
|
67
|
+
expect(result.content[0].text).toContain('session_not_found: no active session with id "s7"');
|
|
68
|
+
expect(result.content[0].text).toContain('disconnected or expired');
|
|
69
|
+
expect(mockState.pruneDisconnectedSessions).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(mockState.resolveSession).toHaveBeenCalledWith('s7');
|
|
71
|
+
});
|
|
72
|
+
it('preserves ambiguous-session errors after pruning disconnected sessions', async () => {
|
|
73
|
+
const handler = getToolHandler('geometra_query');
|
|
74
|
+
mockState.pruneDisconnectedSessions.mockReturnValue(['s3']);
|
|
75
|
+
mockState.resolveSession.mockReturnValue({
|
|
76
|
+
kind: 'ambiguous',
|
|
77
|
+
activeIds: ['s1', 's2'],
|
|
78
|
+
isolatedIds: ['s2'],
|
|
79
|
+
});
|
|
80
|
+
const result = await handler({ role: 'button' });
|
|
81
|
+
expect(result.isError).toBe(true);
|
|
82
|
+
expect(result.content[0].text).toContain('multiple_active_sessions_provide_id');
|
|
83
|
+
expect(result.content[0].text).toContain('s1, s2');
|
|
84
|
+
expect(result.content[0].text).toContain('isolated: s2');
|
|
85
|
+
expect(mockState.pruneDisconnectedSessions).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(mockState.resolveSession).toHaveBeenCalledWith(undefined);
|
|
87
|
+
});
|
|
88
|
+
});
|
package/dist/connect-utils.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface NormalizedConnectTarget {
|
|
|
4
4
|
pageUrl?: string;
|
|
5
5
|
wsUrl?: string;
|
|
6
6
|
}
|
|
7
|
+
/** Returned when `geometra_connect` omits both `url` and `pageUrl`, or supplies both at once. */
|
|
8
|
+
export declare const CONNECT_TARGET_EXACTLY_ONE_ERROR = "Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://\u2026).";
|
|
7
9
|
export declare function normalizeConnectTarget(input: {
|
|
8
10
|
url?: string;
|
|
9
11
|
pageUrl?: string;
|
package/dist/connect-utils.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
/** Returned when `geometra_connect` omits both `url` and `pageUrl`, or supplies both at once. */
|
|
2
|
+
export const CONNECT_TARGET_EXACTLY_ONE_ERROR = 'Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://…).';
|
|
1
3
|
export function normalizeConnectTarget(input) {
|
|
2
4
|
const rawUrl = normalizeOptional(input.url);
|
|
3
5
|
const rawPageUrl = normalizeOptional(input.pageUrl);
|
|
4
6
|
if (rawUrl && rawPageUrl) {
|
|
5
|
-
return { ok: false, error:
|
|
7
|
+
return { ok: false, error: CONNECT_TARGET_EXACTLY_ONE_ERROR };
|
|
6
8
|
}
|
|
7
9
|
if (!rawUrl && !rawPageUrl) {
|
|
8
|
-
return { ok: false, error:
|
|
10
|
+
return { ok: false, error: CONNECT_TARGET_EXACTLY_ONE_ERROR };
|
|
9
11
|
}
|
|
10
12
|
if (rawPageUrl) {
|
|
11
13
|
const parsed = parseUrl(rawPageUrl);
|
|
@@ -60,7 +62,7 @@ export function formatConnectFailureMessage(err, target) {
|
|
|
60
62
|
const base = err instanceof Error ? err.message : String(err);
|
|
61
63
|
const hints = [];
|
|
62
64
|
if (target.kind === 'ws' &&
|
|
63
|
-
/ECONNREFUSED|timed out|closed before first frame|WebSocket error connecting/i.test(base)) {
|
|
65
|
+
/ECONNREFUSED|ENOTFOUND|getaddrinfo|timed out|closed before first frame|WebSocket error connecting/i.test(base)) {
|
|
64
66
|
hints.push('If this is a normal website, call geometra_connect with pageUrl: "https://…" so MCP can start @geometra/proxy for you.');
|
|
65
67
|
}
|
|
66
68
|
if (/Could not resolve @geometra\/proxy/i.test(base)) {
|