@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.
@@ -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: 'Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://…).',
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: 'Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://…).',
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: vi.fn(() => null),
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
+ });
@@ -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;
@@ -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: 'Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://…).' };
7
+ return { ok: false, error: CONNECT_TARGET_EXACTLY_ONE_ERROR };
6
8
  }
7
9
  if (!rawUrl && !rawPageUrl) {
8
- return { ok: false, error: 'Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://…).' };
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)) {