@geometra/mcp 1.49.0 → 1.52.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 +127 -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 +89 -4
- 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, {
|
|
@@ -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)) {
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks';
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
6
|
-
import { connect, connectThroughProxy, disconnect, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
6
|
+
import { connect, connectThroughProxy, disconnect, pruneDisconnectedSessions, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
7
7
|
function checkedStateInput() {
|
|
8
8
|
return z
|
|
9
9
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -124,6 +124,17 @@ const fillFieldSchema = z.union([
|
|
|
124
124
|
fieldLabel: z.string().describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
|
|
125
125
|
value: z.string().describe('Text value to set'),
|
|
126
126
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
127
|
+
typingDelayMs: z
|
|
128
|
+
.number()
|
|
129
|
+
.int()
|
|
130
|
+
.min(0)
|
|
131
|
+
.max(500)
|
|
132
|
+
.optional()
|
|
133
|
+
.describe('Milliseconds between keystrokes when the proxy falls back to keyboard typing (masked inputs).'),
|
|
134
|
+
imeFriendly: z
|
|
135
|
+
.boolean()
|
|
136
|
+
.optional()
|
|
137
|
+
.describe('Use composition-friendly events for IME-heavy controlled fields.'),
|
|
127
138
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
128
139
|
}),
|
|
129
140
|
z.object({
|
|
@@ -132,6 +143,17 @@ const fillFieldSchema = z.union([
|
|
|
132
143
|
fieldLabel: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
|
|
133
144
|
value: z.string().describe('Text value to set'),
|
|
134
145
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
146
|
+
typingDelayMs: z
|
|
147
|
+
.number()
|
|
148
|
+
.int()
|
|
149
|
+
.min(0)
|
|
150
|
+
.max(500)
|
|
151
|
+
.optional()
|
|
152
|
+
.describe('Milliseconds between keystrokes when the proxy falls back to keyboard typing (masked inputs).'),
|
|
153
|
+
imeFriendly: z
|
|
154
|
+
.boolean()
|
|
155
|
+
.optional()
|
|
156
|
+
.describe('Use composition-friendly events for IME-heavy controlled fields.'),
|
|
135
157
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
136
158
|
}),
|
|
137
159
|
z.object({
|
|
@@ -284,6 +306,27 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
284
306
|
z.object({
|
|
285
307
|
type: z.literal('fill_fields'),
|
|
286
308
|
fields: z.array(fillFieldSchema).min(1).max(80),
|
|
309
|
+
verifyFills: z
|
|
310
|
+
.boolean()
|
|
311
|
+
.optional()
|
|
312
|
+
.describe('After filling, read each text/choice field back and flag mismatches (e.g. autocomplete rejected input, format transformed). Adds a `verification` entry to the step.'),
|
|
313
|
+
}),
|
|
314
|
+
z.object({
|
|
315
|
+
type: z.literal('expand_section'),
|
|
316
|
+
id: z.string().describe('Stable section id from geometra_page_model (e.g. fm:1.0, ls:2.1).'),
|
|
317
|
+
maxHeadings: z.number().int().min(1).max(20).optional(),
|
|
318
|
+
maxFields: z.number().int().min(1).max(40).optional(),
|
|
319
|
+
fieldOffset: z.number().int().min(0).optional(),
|
|
320
|
+
onlyRequiredFields: z.boolean().optional(),
|
|
321
|
+
onlyInvalidFields: z.boolean().optional(),
|
|
322
|
+
maxActions: z.number().int().min(1).max(30).optional(),
|
|
323
|
+
actionOffset: z.number().int().min(0).optional(),
|
|
324
|
+
maxLists: z.number().int().min(0).max(20).optional(),
|
|
325
|
+
listOffset: z.number().int().min(0).optional(),
|
|
326
|
+
maxItems: z.number().int().min(0).max(50).optional(),
|
|
327
|
+
itemOffset: z.number().int().min(0).optional(),
|
|
328
|
+
maxTextPreview: z.number().int().min(0).max(20).optional(),
|
|
329
|
+
includeBounds: z.boolean().optional(),
|
|
287
330
|
}),
|
|
288
331
|
]);
|
|
289
332
|
export function createServer() {
|
|
@@ -1115,7 +1158,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1115
1158
|
});
|
|
1116
1159
|
server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
|
|
1117
1160
|
|
|
1118
|
-
Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`. \`click\` steps can also carry a nested \`waitFor\` condition. Pass \`pageUrl\` / \`url\` to auto-connect so an entire flow can run in one MCP call.`, {
|
|
1161
|
+
Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, \`expand_section\`, and \`fill_fields\`. \`click\` steps can also carry a nested \`waitFor\` condition. \`fill_fields\` steps can carry \`verifyFills: true\` to batch fill + read-back verification in one step (same semantics as \`geometra_fill_form\`'s \`verifyFills\`). \`expand_section\` takes a stable section id from \`geometra_page_model\` and returns the same payload as \`geometra_expand_section\`, eliminating a round-trip when drilling into a form/dialog before acting on it. Pass \`pageUrl\` / \`url\` to auto-connect so an entire flow can run in one MCP call.`, {
|
|
1119
1162
|
url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before running actions.'),
|
|
1120
1163
|
pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before running actions. Prefer this over url for browser pages.'),
|
|
1121
1164
|
port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
|
|
@@ -3225,6 +3268,7 @@ function batchFieldReadbackMatches(a11y, field) {
|
|
|
3225
3268
|
function actionNeedsUiTree(action) {
|
|
3226
3269
|
switch (action.type) {
|
|
3227
3270
|
case 'wait_for':
|
|
3271
|
+
case 'expand_section':
|
|
3228
3272
|
return true;
|
|
3229
3273
|
case 'click':
|
|
3230
3274
|
return action.x === undefined || action.y === undefined || Boolean(action.waitFor);
|
|
@@ -3480,13 +3524,45 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3480
3524
|
compact: waitConditionCompact(waited.value),
|
|
3481
3525
|
};
|
|
3482
3526
|
}
|
|
3527
|
+
case 'expand_section': {
|
|
3528
|
+
const a11y = sessionA11y(session);
|
|
3529
|
+
if (!a11y)
|
|
3530
|
+
throw new Error('No UI tree available to expand section');
|
|
3531
|
+
const sectionDetail = expandPageSection(a11y, action.id, {
|
|
3532
|
+
maxHeadings: action.maxHeadings,
|
|
3533
|
+
maxFields: action.maxFields,
|
|
3534
|
+
fieldOffset: action.fieldOffset,
|
|
3535
|
+
onlyRequiredFields: action.onlyRequiredFields,
|
|
3536
|
+
onlyInvalidFields: action.onlyInvalidFields,
|
|
3537
|
+
maxActions: action.maxActions,
|
|
3538
|
+
actionOffset: action.actionOffset,
|
|
3539
|
+
maxLists: action.maxLists,
|
|
3540
|
+
listOffset: action.listOffset,
|
|
3541
|
+
maxItems: action.maxItems,
|
|
3542
|
+
itemOffset: action.itemOffset,
|
|
3543
|
+
maxTextPreview: action.maxTextPreview,
|
|
3544
|
+
includeBounds: action.includeBounds,
|
|
3545
|
+
});
|
|
3546
|
+
if (!sectionDetail)
|
|
3547
|
+
throw new Error(`No expandable section found for id ${action.id}`);
|
|
3548
|
+
return {
|
|
3549
|
+
summary: detail === 'verbose'
|
|
3550
|
+
? JSON.stringify(sectionDetail, null, 2)
|
|
3551
|
+
: `Expanded section "${action.id}".`,
|
|
3552
|
+
compact: { id: action.id, detail: sectionDetail },
|
|
3553
|
+
};
|
|
3554
|
+
}
|
|
3483
3555
|
case 'fill_fields': {
|
|
3484
3556
|
const resolvedFields = resolveFillFieldInputs(session, action.fields);
|
|
3485
3557
|
if (!resolvedFields.ok)
|
|
3486
3558
|
throw new Error(resolvedFields.error);
|
|
3559
|
+
const verifyFillsFn = action.verifyFills
|
|
3560
|
+
? () => verifyFormFills(session, resolvedFields.fields.map(field => ({ field, confidence: 1.0, matchMethod: 'label-exact' })))
|
|
3561
|
+
: undefined;
|
|
3487
3562
|
if (!includeSteps) {
|
|
3488
3563
|
const batched = await tryBatchedResolvedFields(session, resolvedFields.fields, detail);
|
|
3489
3564
|
if (batched.ok) {
|
|
3565
|
+
const verification = verifyFillsFn?.();
|
|
3490
3566
|
return {
|
|
3491
3567
|
summary: `Filled ${resolvedFields.fields.length} field(s) in one proxy batch.`,
|
|
3492
3568
|
compact: {
|
|
@@ -3494,6 +3570,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3494
3570
|
execution: 'batched',
|
|
3495
3571
|
finalSource: batched.finalSource,
|
|
3496
3572
|
final: batched.final,
|
|
3573
|
+
...(verification ? { verification } : {}),
|
|
3497
3574
|
},
|
|
3498
3575
|
};
|
|
3499
3576
|
}
|
|
@@ -3519,11 +3596,13 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3519
3596
|
? { index, kind: field.kind, ok, summary: result.summary }
|
|
3520
3597
|
: { index, kind: field.kind, ok, ...result.compact });
|
|
3521
3598
|
}
|
|
3599
|
+
const verification = verifyFillsFn?.();
|
|
3522
3600
|
return {
|
|
3523
3601
|
summary: steps.map(step => String(step.summary ?? '')).filter(Boolean).join('\n'),
|
|
3524
3602
|
compact: {
|
|
3525
3603
|
fieldCount: resolvedFields.fields.length,
|
|
3526
3604
|
...(includeSteps ? { steps } : {}),
|
|
3605
|
+
...(verification ? { verification } : {}),
|
|
3527
3606
|
},
|
|
3528
3607
|
};
|
|
3529
3608
|
}
|
|
@@ -3674,7 +3753,12 @@ async function executeFillField(session, field, detail) {
|
|
|
3674
3753
|
switch (field.kind) {
|
|
3675
3754
|
case 'text': {
|
|
3676
3755
|
const before = sessionA11y(session);
|
|
3677
|
-
const wait = await sendFieldText(session, field.fieldLabel, field.value, {
|
|
3756
|
+
const wait = await sendFieldText(session, field.fieldLabel, field.value, {
|
|
3757
|
+
exact: field.exact,
|
|
3758
|
+
fieldId: field.fieldId,
|
|
3759
|
+
typingDelayMs: field.typingDelayMs,
|
|
3760
|
+
imeFriendly: field.imeFriendly,
|
|
3761
|
+
}, field.timeoutMs);
|
|
3678
3762
|
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
3679
3763
|
return {
|
|
3680
3764
|
summary: [
|
|
@@ -3806,6 +3890,7 @@ function err(text) {
|
|
|
3806
3890
|
* const session = sessionResult.session
|
|
3807
3891
|
*/
|
|
3808
3892
|
function resolveToolSession(sessionId) {
|
|
3893
|
+
pruneDisconnectedSessions();
|
|
3809
3894
|
const result = resolveSession(sessionId);
|
|
3810
3895
|
switch (result.kind) {
|
|
3811
3896
|
case 'ok':
|
|
@@ -3814,7 +3899,7 @@ function resolveToolSession(sessionId) {
|
|
|
3814
3899
|
return { error: err('Not connected. Call geometra_connect first.') };
|
|
3815
3900
|
case 'not_found':
|
|
3816
3901
|
return {
|
|
3817
|
-
error: err(`session_not_found: no active session with id "${result.id}". Active sessions: ${result.activeIds.length > 0 ? result.activeIds.join(', ') : '(none)'}.
|
|
3902
|
+
error: err(`session_not_found: no active session with id "${result.id}". Active sessions: ${result.activeIds.length > 0 ? result.activeIds.join(', ') : '(none)'}. The requested session may have disconnected or expired; call geometra_connect again to start a new session — the MCP server never silently routes an explicit sessionId onto a different session.`),
|
|
3818
3903
|
};
|
|
3819
3904
|
case 'ambiguous': {
|
|
3820
3905
|
const isolatedSuffix = result.isolatedIds.length > 0
|
package/dist/session.d.ts
CHANGED
|
@@ -420,6 +420,7 @@ export interface Session {
|
|
|
420
420
|
forms: FormSchemaModel[];
|
|
421
421
|
}>;
|
|
422
422
|
workflowState?: WorkflowState;
|
|
423
|
+
reconnectInFlight?: Promise<boolean>;
|
|
423
424
|
}
|
|
424
425
|
export interface SessionConnectTrace {
|
|
425
426
|
mode: 'direct-ws' | 'fresh-proxy' | 'reused-proxy';
|
|
@@ -453,6 +454,8 @@ export type ProxyFillField = {
|
|
|
453
454
|
fieldLabel: string;
|
|
454
455
|
value: string;
|
|
455
456
|
exact?: boolean;
|
|
457
|
+
typingDelayMs?: number;
|
|
458
|
+
imeFriendly?: boolean;
|
|
456
459
|
} | {
|
|
457
460
|
kind: 'choice';
|
|
458
461
|
fieldId?: string;
|
|
@@ -526,6 +529,7 @@ export declare function connectThroughProxy(options: {
|
|
|
526
529
|
isolated?: boolean;
|
|
527
530
|
}): Promise<Session>;
|
|
528
531
|
export declare function getSession(id?: string): Session | null;
|
|
532
|
+
export declare function pruneDisconnectedSessions(): string[];
|
|
529
533
|
/**
|
|
530
534
|
* Tool-side session resolution with strict routing semantics.
|
|
531
535
|
*
|
|
@@ -608,6 +612,8 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
|
|
|
608
612
|
export declare function sendFieldText(session: Session, fieldLabel: string, value: string, opts?: {
|
|
609
613
|
exact?: boolean;
|
|
610
614
|
fieldId?: string;
|
|
615
|
+
typingDelayMs?: number;
|
|
616
|
+
imeFriendly?: boolean;
|
|
611
617
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
612
618
|
/** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
|
|
613
619
|
export declare function sendFieldChoice(session: Session, fieldLabel: string, value: string, opts?: {
|
package/dist/session.js
CHANGED
|
@@ -22,6 +22,7 @@ const FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS = 500;
|
|
|
22
22
|
const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 225;
|
|
23
23
|
const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
|
|
24
24
|
const FILL_BATCH_MAX_TIMEOUT_MS = 60_000;
|
|
25
|
+
const SESSION_RECONNECT_TIMEOUT_MS = 5_000;
|
|
25
26
|
let nextRequestSequence = 0;
|
|
26
27
|
function invalidateSessionCaches(session) {
|
|
27
28
|
session.cachedA11y = null;
|
|
@@ -682,6 +683,8 @@ export function connect(url, opts) {
|
|
|
682
683
|
heartbeatInterval.unref();
|
|
683
684
|
}
|
|
684
685
|
ws.on('pong', () => {
|
|
686
|
+
if (session.ws !== ws)
|
|
687
|
+
return;
|
|
685
688
|
lastMessageAt = Date.now();
|
|
686
689
|
pendingPongBy = null;
|
|
687
690
|
});
|
|
@@ -693,6 +696,8 @@ export function connect(url, opts) {
|
|
|
693
696
|
}
|
|
694
697
|
}, 10_000);
|
|
695
698
|
ws.on('open', () => {
|
|
699
|
+
if (session.ws !== ws)
|
|
700
|
+
return;
|
|
696
701
|
if (session.connectTrace) {
|
|
697
702
|
session.connectTrace.wsOpenMs = performance.now() - startedAt;
|
|
698
703
|
}
|
|
@@ -715,6 +720,8 @@ export function connect(url, opts) {
|
|
|
715
720
|
}
|
|
716
721
|
});
|
|
717
722
|
ws.on('message', (data) => {
|
|
723
|
+
if (session.ws !== ws)
|
|
724
|
+
return;
|
|
718
725
|
lastMessageAt = Date.now();
|
|
719
726
|
try {
|
|
720
727
|
const msg = JSON.parse(String(data));
|
|
@@ -755,6 +762,8 @@ export function connect(url, opts) {
|
|
|
755
762
|
}
|
|
756
763
|
});
|
|
757
764
|
ws.on('close', () => {
|
|
765
|
+
if (session.ws !== ws)
|
|
766
|
+
return;
|
|
758
767
|
if (heartbeatInterval) {
|
|
759
768
|
clearInterval(heartbeatInterval);
|
|
760
769
|
heartbeatInterval = null;
|
|
@@ -846,6 +855,19 @@ export function getSession(id) {
|
|
|
846
855
|
return activeSessions.get(defaultSessionId) ?? null;
|
|
847
856
|
return null;
|
|
848
857
|
}
|
|
858
|
+
export function pruneDisconnectedSessions() {
|
|
859
|
+
const removedIds = [];
|
|
860
|
+
for (const [id, session] of activeSessions.entries()) {
|
|
861
|
+
if (session.ws.readyState === WebSocket.OPEN)
|
|
862
|
+
continue;
|
|
863
|
+
removedIds.push(id);
|
|
864
|
+
activeSessions.delete(id);
|
|
865
|
+
if (defaultSessionId === id) {
|
|
866
|
+
promoteDefaultSession();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return removedIds;
|
|
870
|
+
}
|
|
849
871
|
export function resolveSession(id) {
|
|
850
872
|
if (id) {
|
|
851
873
|
const found = activeSessions.get(id);
|
|
@@ -903,6 +925,9 @@ function estimateFillBatchTimeout(fields) {
|
|
|
903
925
|
totalTextLength += field.value.length;
|
|
904
926
|
total += FILL_BATCH_TEXT_FIELD_TIMEOUT_MS;
|
|
905
927
|
total += Math.ceil(Math.max(1, field.value.length) / FILL_BATCH_TEXT_LENGTH_SLICE) * FILL_BATCH_TEXT_LENGTH_TIMEOUT_MS;
|
|
928
|
+
if (field.typingDelayMs !== undefined) {
|
|
929
|
+
total += field.typingDelayMs * field.value.length;
|
|
930
|
+
}
|
|
906
931
|
break;
|
|
907
932
|
case 'choice':
|
|
908
933
|
total += field.choiceType === 'group' ? FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS : FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
@@ -956,16 +981,128 @@ export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
|
956
981
|
check();
|
|
957
982
|
});
|
|
958
983
|
}
|
|
959
|
-
function
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
984
|
+
function reconnectUrlForSession(session) {
|
|
985
|
+
if (session.proxyRuntime && typeof session.proxyRuntime.wsUrl === 'string') {
|
|
986
|
+
return session.proxyRuntime.wsUrl;
|
|
987
|
+
}
|
|
988
|
+
const pooled = reusableProxyEntryForSession(session);
|
|
989
|
+
if (pooled) {
|
|
990
|
+
return pooled.wsUrl;
|
|
991
|
+
}
|
|
992
|
+
if (typeof session.url === 'string' && /^wss?:\/\//i.test(session.url)) {
|
|
993
|
+
return session.url;
|
|
994
|
+
}
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
|
|
998
|
+
return await new Promise((resolve, reject) => {
|
|
999
|
+
const ws = new WebSocket(url);
|
|
1000
|
+
const timeout = setTimeout(() => {
|
|
1001
|
+
cleanup();
|
|
1002
|
+
try {
|
|
1003
|
+
ws.close();
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
/* ignore */
|
|
1007
|
+
}
|
|
1008
|
+
reject(new Error(`Reconnect to ${url} timed out after ${timeoutMs}ms`));
|
|
1009
|
+
}, timeoutMs);
|
|
1010
|
+
const onOpen = () => {
|
|
1011
|
+
cleanup();
|
|
1012
|
+
resolve(ws);
|
|
1013
|
+
};
|
|
1014
|
+
const onError = (err) => {
|
|
1015
|
+
cleanup();
|
|
1016
|
+
reject(new Error(`WebSocket reconnect failed for ${url}: ${err.message}`));
|
|
1017
|
+
};
|
|
1018
|
+
function cleanup() {
|
|
1019
|
+
clearTimeout(timeout);
|
|
1020
|
+
ws.off('open', onOpen);
|
|
1021
|
+
ws.off('error', onError);
|
|
1022
|
+
}
|
|
1023
|
+
ws.on('open', onOpen);
|
|
1024
|
+
ws.on('error', onError);
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
function bindReconnectedSocket(session, ws) {
|
|
1028
|
+
ws.on('message', data => {
|
|
1029
|
+
if (session.ws !== ws)
|
|
963
1030
|
return;
|
|
1031
|
+
try {
|
|
1032
|
+
const msg = JSON.parse(String(data));
|
|
1033
|
+
if (msg.type === 'frame') {
|
|
1034
|
+
session.layout = msg.layout;
|
|
1035
|
+
session.tree = msg.tree;
|
|
1036
|
+
session.updateRevision++;
|
|
1037
|
+
invalidateSessionCaches(session);
|
|
1038
|
+
}
|
|
1039
|
+
else if (msg.type === 'patch' && session.layout) {
|
|
1040
|
+
applyPatches(session.layout, msg.patches);
|
|
1041
|
+
session.updateRevision++;
|
|
1042
|
+
invalidateSessionCaches(session);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
/* ignore malformed messages */
|
|
964
1047
|
}
|
|
965
|
-
const startRevision = session.updateRevision;
|
|
966
|
-
session.ws.send(JSON.stringify({ type: 'resize', width, height }));
|
|
967
|
-
waitForNextUpdate(session, timeoutMs, undefined, startRevision).then(resolve).catch(reject);
|
|
968
1048
|
});
|
|
1049
|
+
ws.on('close', () => {
|
|
1050
|
+
if (session.ws !== ws)
|
|
1051
|
+
return;
|
|
1052
|
+
if (activeSessions.get(session.id) === session) {
|
|
1053
|
+
activeSessions.delete(session.id);
|
|
1054
|
+
if (defaultSessionId === session.id)
|
|
1055
|
+
promoteDefaultSession();
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
async function ensureSessionConnected(session) {
|
|
1060
|
+
if (session.ws.readyState === WebSocket.OPEN)
|
|
1061
|
+
return;
|
|
1062
|
+
if (session.reconnectInFlight) {
|
|
1063
|
+
const recovered = await session.reconnectInFlight;
|
|
1064
|
+
if (!recovered) {
|
|
1065
|
+
throw new Error('Not connected');
|
|
1066
|
+
}
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const targetUrl = reconnectUrlForSession(session);
|
|
1070
|
+
if (!targetUrl) {
|
|
1071
|
+
throw new Error('Not connected');
|
|
1072
|
+
}
|
|
1073
|
+
const reconnectPromise = (async () => {
|
|
1074
|
+
const nextWs = await openWebSocket(targetUrl);
|
|
1075
|
+
try {
|
|
1076
|
+
session.ws.close();
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
/* ignore */
|
|
1080
|
+
}
|
|
1081
|
+
session.ws = nextWs;
|
|
1082
|
+
bindReconnectedSocket(session, nextWs);
|
|
1083
|
+
activeSessions.set(session.id, session);
|
|
1084
|
+
if (!session.isolated) {
|
|
1085
|
+
defaultSessionId = session.id;
|
|
1086
|
+
}
|
|
1087
|
+
return true;
|
|
1088
|
+
})();
|
|
1089
|
+
session.reconnectInFlight = reconnectPromise;
|
|
1090
|
+
let recovered = false;
|
|
1091
|
+
try {
|
|
1092
|
+
recovered = await reconnectPromise;
|
|
1093
|
+
}
|
|
1094
|
+
finally {
|
|
1095
|
+
session.reconnectInFlight = undefined;
|
|
1096
|
+
}
|
|
1097
|
+
if (!recovered) {
|
|
1098
|
+
throw new Error('Not connected');
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
async function sendResizeAndWaitForUpdate(session, width, height, timeoutMs = 5_000) {
|
|
1102
|
+
await ensureSessionConnected(session);
|
|
1103
|
+
const startRevision = session.updateRevision;
|
|
1104
|
+
session.ws.send(JSON.stringify({ type: 'resize', width, height }));
|
|
1105
|
+
return await waitForNextUpdate(session, timeoutMs, undefined, startRevision);
|
|
969
1106
|
}
|
|
970
1107
|
/**
|
|
971
1108
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
@@ -982,11 +1119,8 @@ export function sendClick(session, x, y, timeoutMs) {
|
|
|
982
1119
|
* Send a sequence of key events to type text into the focused element.
|
|
983
1120
|
*/
|
|
984
1121
|
export function sendType(session, text, timeoutMs) {
|
|
985
|
-
return
|
|
986
|
-
|
|
987
|
-
reject(new Error('Not connected'));
|
|
988
|
-
return;
|
|
989
|
-
}
|
|
1122
|
+
return (async () => {
|
|
1123
|
+
await ensureSessionConnected(session);
|
|
990
1124
|
// Send each character as keydown + keyup
|
|
991
1125
|
for (const char of text) {
|
|
992
1126
|
const keyEvent = {
|
|
@@ -1003,8 +1137,8 @@ export function sendType(session, text, timeoutMs) {
|
|
|
1003
1137
|
session.ws.send(JSON.stringify({ ...keyEvent, eventType: 'onKeyUp' }));
|
|
1004
1138
|
}
|
|
1005
1139
|
// Wait briefly for server to process and send update
|
|
1006
|
-
waitForNextUpdate(session, timeoutMs)
|
|
1007
|
-
});
|
|
1140
|
+
return await waitForNextUpdate(session, timeoutMs);
|
|
1141
|
+
})();
|
|
1008
1142
|
}
|
|
1009
1143
|
/**
|
|
1010
1144
|
* Send a special key (Enter, Tab, Escape, etc.)
|
|
@@ -1054,6 +1188,10 @@ export function sendFieldText(session, fieldLabel, value, opts, timeoutMs) {
|
|
|
1054
1188
|
payload.exact = opts.exact;
|
|
1055
1189
|
if (opts?.fieldId)
|
|
1056
1190
|
payload.fieldId = opts.fieldId;
|
|
1191
|
+
if (opts?.typingDelayMs !== undefined)
|
|
1192
|
+
payload.typingDelayMs = opts.typingDelayMs;
|
|
1193
|
+
if (opts?.imeFriendly !== undefined)
|
|
1194
|
+
payload.imeFriendly = opts.imeFriendly;
|
|
1057
1195
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
1058
1196
|
}
|
|
1059
1197
|
/** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
|
|
@@ -2955,17 +3093,12 @@ function applyPatches(layout, patches) {
|
|
|
2955
3093
|
node.height = patch.height;
|
|
2956
3094
|
}
|
|
2957
3095
|
}
|
|
2958
|
-
function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
const requestId = `req-${++nextRequestSequence}`;
|
|
2965
|
-
const startRevision = session.updateRevision;
|
|
2966
|
-
session.ws.send(JSON.stringify({ ...message, requestId }));
|
|
2967
|
-
waitForNextUpdate(session, timeoutMs, requestId, startRevision, opts).then(resolve).catch(reject);
|
|
2968
|
-
});
|
|
3096
|
+
async function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
|
|
3097
|
+
await ensureSessionConnected(session);
|
|
3098
|
+
const requestId = `req-${++nextRequestSequence}`;
|
|
3099
|
+
const startRevision = session.updateRevision;
|
|
3100
|
+
session.ws.send(JSON.stringify({ ...message, requestId }));
|
|
3101
|
+
return await waitForNextUpdate(session, timeoutMs, requestId, startRevision, opts);
|
|
2969
3102
|
}
|
|
2970
3103
|
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision, opts) {
|
|
2971
3104
|
return new Promise((resolve, reject) => {
|
package/package.json
CHANGED