@geometra/mcp 1.59.1 → 1.61.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.
@@ -1 +0,0 @@
1
- export {};
@@ -1,57 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { findNodes } from '../server.js';
3
- function node(role, bounds, options) {
4
- return {
5
- role,
6
- ...(options?.name ? { name: options.name } : {}),
7
- ...(options?.value ? { value: options.value } : {}),
8
- ...(options?.state ? { state: options.state } : {}),
9
- ...(options?.validation ? { validation: options.validation } : {}),
10
- bounds,
11
- path: options?.path ?? [],
12
- children: options?.children ?? [],
13
- focusable: options?.focusable ?? false,
14
- };
15
- }
16
- describe('findNodes', () => {
17
- it('matches value and checked-state filters generically', () => {
18
- const tree = node('group', { x: 0, y: 0, width: 800, height: 600 }, {
19
- children: [
20
- node('combobox', { x: 20, y: 20, width: 260, height: 36 }, {
21
- path: [0],
22
- name: 'Location',
23
- value: 'Austin, Texas, United States',
24
- focusable: true,
25
- }),
26
- node('textbox', { x: 20, y: 120, width: 260, height: 36 }, {
27
- path: [1],
28
- name: 'Email',
29
- focusable: true,
30
- state: { invalid: true, required: true, busy: true },
31
- validation: { error: 'Please enter a valid email address.' },
32
- }),
33
- node('checkbox', { x: 20, y: 72, width: 24, height: 24 }, {
34
- path: [2],
35
- name: 'Notion Website',
36
- state: { checked: true },
37
- focusable: true,
38
- }),
39
- ],
40
- });
41
- expect(findNodes(tree, { value: 'Austin, Texas' })).toEqual([
42
- expect.objectContaining({ role: 'combobox', name: 'Location' }),
43
- ]);
44
- expect(findNodes(tree, { text: 'United States' })).toEqual([
45
- expect.objectContaining({ role: 'combobox', value: 'Austin, Texas, United States' }),
46
- ]);
47
- expect(findNodes(tree, { invalid: true, required: true, busy: true })).toEqual([
48
- expect.objectContaining({ role: 'textbox', name: 'Email' }),
49
- ]);
50
- expect(findNodes(tree, { text: 'valid email address' })).toEqual([
51
- expect.objectContaining({ role: 'textbox', name: 'Email' }),
52
- ]);
53
- expect(findNodes(tree, { role: 'checkbox', checked: true })).toEqual([
54
- expect.objectContaining({ name: 'Notion Website' }),
55
- ]);
56
- });
57
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,88 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,308 +0,0 @@
1
- /**
2
- * Integration test for `connectThroughProxy({ isolated: true })`.
3
- *
4
- * Setup: a tiny HTTP server serves an HTML page that, on first visit, writes
5
- * a path-tagged marker to `localStorage` if one isn't already set, then
6
- * renders the marker text into an `<h1>`. The marker is therefore set ONCE
7
- * per browser instance, regardless of how many navigations happen.
8
- *
9
- * Two scenarios verify the isolated flag's behavior:
10
- *
11
- * 1. **Pooled (default)**: connect to `/page-a`, disconnect (the proxy
12
- * enters the reusable pool), then connect to `/page-b`. The second
13
- * connect attaches to the same pooled proxy, which navigates the
14
- * existing Chromium from /page-a to /page-b — but the browser's
15
- * localStorage still has `marker-from-/page-a`, so the second session
16
- * SEES THE FIRST SESSION'S MARKER. This documents the contamination
17
- * that breaks parallel form submission against real apply flows.
18
- *
19
- * 2. **Isolated**: same connect/disconnect/connect sequence, but with
20
- * `isolated: true`. Each connect spawns a brand-new Chromium with
21
- * empty storage, so the second session sees its own marker, not the
22
- * first's. This is the fix.
23
- *
24
- * The point of having both cases in one test file is so that future edits
25
- * to the pool code can't quietly break the isolation guarantee — both
26
- * paths run end-to-end against real Chromium and assert on the actual
27
- * post-navigation a11y tree the MCP would expose to a tool call.
28
- */
29
- import { afterEach, beforeAll, describe, expect, it } from 'vitest';
30
- import http from 'node:http';
31
- import { buildA11yTree, connectThroughProxy, disconnect, getDefaultSessionId, resolveSession, } from '../session.js';
32
- const PAGE_HTML = `<!doctype html>
33
- <html>
34
- <head><title>isolation-fixture</title></head>
35
- <body>
36
- <h1 id="marker"></h1>
37
- <script>
38
- const stored = localStorage.getItem('isolation-marker')
39
- if (!stored) {
40
- // First visit in this browser instance — set a path-tagged marker.
41
- localStorage.setItem('isolation-marker', 'marker-from-' + location.pathname)
42
- }
43
- document.getElementById('marker').textContent =
44
- localStorage.getItem('isolation-marker') || 'marker-missing'
45
- </script>
46
- </body>
47
- </html>`;
48
- let baseUrl;
49
- let server;
50
- function findHeadingText(node) {
51
- if (!node)
52
- return undefined;
53
- if (node.role === 'heading') {
54
- const name = (node.name ?? '').trim();
55
- if (name)
56
- return name;
57
- }
58
- for (const child of node.children ?? []) {
59
- const found = findHeadingText(child);
60
- if (found)
61
- return found;
62
- }
63
- return undefined;
64
- }
65
- function currentA11y(session) {
66
- if (!session.tree || !session.layout)
67
- return null;
68
- return buildA11yTree(session.tree, session.layout);
69
- }
70
- async function waitForMarkerText(session, expectedPrefix, timeoutMs = 6_000) {
71
- const deadline = Date.now() + timeoutMs;
72
- while (Date.now() < deadline) {
73
- const text = findHeadingText(currentA11y(session));
74
- if (text && text.startsWith(expectedPrefix))
75
- return text;
76
- await new Promise(r => setTimeout(r, 100));
77
- }
78
- throw new Error(`Timed out waiting for heading text starting with "${expectedPrefix}". ` +
79
- `Last seen: ${JSON.stringify(findHeadingText(currentA11y(session)) ?? null)}`);
80
- }
81
- describe('connectThroughProxy({ isolated: true })', () => {
82
- beforeAll(async () => {
83
- server = http.createServer((req, res) => {
84
- const url = req.url ?? '/';
85
- if (url === '/page-a' || url === '/page-b') {
86
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
87
- res.end(PAGE_HTML);
88
- return;
89
- }
90
- res.writeHead(404);
91
- res.end();
92
- });
93
- await new Promise((resolve, reject) => {
94
- server.once('error', reject);
95
- server.listen(0, '127.0.0.1', () => resolve());
96
- });
97
- const address = server.address();
98
- baseUrl = `http://127.0.0.1:${address.port}`;
99
- });
100
- afterEach(() => {
101
- // Force-close everything so the next test starts with no pooled proxies.
102
- disconnect({ closeProxy: true });
103
- });
104
- it('isolated sessions get independent localStorage between connects', async () => {
105
- // First isolated session against /page-a.
106
- const sessionA = await connectThroughProxy({
107
- pageUrl: `${baseUrl}/page-a`,
108
- headless: true,
109
- isolated: true,
110
- });
111
- expect(sessionA.isolated).toBe(true);
112
- const markerA = await waitForMarkerText(sessionA, 'marker-from-');
113
- expect(markerA).toBe('marker-from-/page-a');
114
- // Disconnect — because the session is isolated, this MUST destroy the
115
- // underlying Chromium. The next connect cannot attach to it.
116
- disconnect({ sessionId: sessionA.id });
117
- // Second isolated session against /page-b. Because each isolated
118
- // session gets its own brand-new Chromium, /page-b's first-visit
119
- // script runs against an empty localStorage and writes its own marker.
120
- const sessionB = await connectThroughProxy({
121
- pageUrl: `${baseUrl}/page-b`,
122
- headless: true,
123
- isolated: true,
124
- });
125
- expect(sessionB.isolated).toBe(true);
126
- const markerB = await waitForMarkerText(sessionB, 'marker-from-');
127
- // Critical assertion: sessionB does NOT see sessionA's marker.
128
- expect(markerB).toBe('marker-from-/page-b');
129
- expect(markerB).not.toBe(markerA);
130
- disconnect({ sessionId: sessionB.id });
131
- }, 30_000);
132
- it('pooled (default) sessions DO leak localStorage — documents the bug isolated fixes', async () => {
133
- // First pooled session against /page-a. The proxy will be eligible
134
- // for reuse after disconnect.
135
- const sessionA = await connectThroughProxy({
136
- pageUrl: `${baseUrl}/page-a`,
137
- headless: true,
138
- // isolated: false (default)
139
- });
140
- expect(sessionA.isolated).toBeFalsy();
141
- const markerA = await waitForMarkerText(sessionA, 'marker-from-');
142
- expect(markerA).toBe('marker-from-/page-a');
143
- // Disconnect WITHOUT closing the proxy — leaves it in the reusable pool.
144
- disconnect({ sessionId: sessionA.id, closeProxy: false });
145
- // Second pooled session against a *different* URL. The pool will
146
- // attach the existing Chromium and navigate it to /page-b, but the
147
- // browser still has /page-a's localStorage, so /page-b's first-visit
148
- // script sees the existing marker and doesn't overwrite it.
149
- const sessionB = await connectThroughProxy({
150
- pageUrl: `${baseUrl}/page-b`,
151
- headless: true,
152
- });
153
- const markerB = await waitForMarkerText(sessionB, 'marker-from-');
154
- // Documents the contamination: the second session sees the first
155
- // session's marker because they share a browser via the pool.
156
- expect(markerB).toBe('marker-from-/page-a');
157
- disconnect({ sessionId: sessionB.id, closeProxy: true });
158
- }, 30_000);
159
- it('serializes concurrent default connects to a pooled proxy onto a single session', async () => {
160
- // Regression for the per-proxy attach race. Before the attachLock fix,
161
- // two concurrent connectThroughProxy calls that both picked the same
162
- // pooled proxy entry could both pass attachToReusableProxy's
163
- // "reusedExistingSession" check (because neither's session was in
164
- // activeSessions yet — connect() runs first) and then both call
165
- // connect(proxy.wsUrl), creating two distinct WebSocket sessions
166
- // bound to the same Chromium. Two agents would silently mutate the
167
- // same DOM. With the lock, the second connect waits for the first,
168
- // re-picks via findReusableProxy, and takes the reusedExistingSession
169
- // branch — both calls return the same Session object.
170
- //
171
- // Step 1: warm the pool with a single connect+disconnect so the next
172
- // connects find an existing entry. Cold-start parallel connects
173
- // legitimately create separate browsers (no shared state to leak), so
174
- // the race only matters when the pool is already populated.
175
- const warmup = await connectThroughProxy({
176
- pageUrl: `${baseUrl}/page-a`,
177
- headless: true,
178
- });
179
- disconnect({ sessionId: warmup.id, closeProxy: false });
180
- // Step 2: fire two concurrent connects. Without the lock, both would
181
- // call connect(proxy.wsUrl) and create distinct sessions bound to the
182
- // same browser. With the lock, the second waits for the first to bind,
183
- // then sees the bound session and reuses it.
184
- const [sessionA, sessionB] = await Promise.all([
185
- connectThroughProxy({ pageUrl: `${baseUrl}/page-a`, headless: true }),
186
- connectThroughProxy({ pageUrl: `${baseUrl}/page-a`, headless: true }),
187
- ]);
188
- // Both connects must converge on the same underlying session. If they
189
- // don't, the race re-emerged: two agents would race in the same browser.
190
- expect(sessionA.id).toBe(sessionB.id);
191
- disconnect({ sessionId: sessionA.id, closeProxy: true });
192
- }, 30_000);
193
- // ── Bug #1 regression: parallel isolated sessions must never share a
194
- // default pointer, and tool-call resolution must refuse to implicitly
195
- // route to an isolated session when no sessionId is passed.
196
- it('three parallel isolated sessions stay strictly independent — no shared default, strict id routing', async () => {
197
- // Spawn three isolated sessions in parallel, each pinned to a
198
- // different local URL. Each session gets its own Chromium instance
199
- // (isolated: true forces a fresh proxy every time), and each tool
200
- // call in real usage would address its session by the returned id.
201
- const [sessionA, sessionB, sessionC] = await Promise.all([
202
- connectThroughProxy({
203
- pageUrl: `${baseUrl}/page-a`,
204
- headless: true,
205
- isolated: true,
206
- }),
207
- connectThroughProxy({
208
- pageUrl: `${baseUrl}/page-b`,
209
- headless: true,
210
- isolated: true,
211
- }),
212
- connectThroughProxy({
213
- pageUrl: `${baseUrl}/page-a`,
214
- headless: true,
215
- isolated: true,
216
- }),
217
- ]);
218
- try {
219
- // Invariant 1: every isolated session is flagged, and all three got
220
- // distinct session ids (no collision).
221
- expect(sessionA.isolated).toBe(true);
222
- expect(sessionB.isolated).toBe(true);
223
- expect(sessionC.isolated).toBe(true);
224
- expect(new Set([sessionA.id, sessionB.id, sessionC.id]).size).toBe(3);
225
- // Invariant 2: the implicit default-session pointer must not be set
226
- // to any of these isolated sessions. This is the contamination fix —
227
- // before v1.43, `connect()` unconditionally set defaultSessionId to
228
- // whichever session most recently finished connecting, so parallel
229
- // workers without an explicit sessionId would race onto each
230
- // other's browsers.
231
- const implicitDefault = getDefaultSessionId();
232
- expect(implicitDefault).toBeNull();
233
- // Invariant 3: resolveSession() with no id must refuse to pick any
234
- // of these isolated sessions. Callers that omit sessionId get an
235
- // ambiguous error and are forced to pass an explicit id.
236
- const ambiguous = resolveSession();
237
- expect(ambiguous.kind).toBe('ambiguous');
238
- if (ambiguous.kind === 'ambiguous') {
239
- expect(ambiguous.activeIds).toEqual(expect.arrayContaining([sessionA.id, sessionB.id, sessionC.id]));
240
- expect(ambiguous.isolatedIds).toEqual(expect.arrayContaining([sessionA.id, sessionB.id, sessionC.id]));
241
- }
242
- // Invariant 4: explicit id routing must return exactly the
243
- // requested session — never a peer — even under parallel load.
244
- const lookupA = resolveSession(sessionA.id);
245
- const lookupB = resolveSession(sessionB.id);
246
- const lookupC = resolveSession(sessionC.id);
247
- expect(lookupA.kind).toBe('ok');
248
- expect(lookupB.kind).toBe('ok');
249
- expect(lookupC.kind).toBe('ok');
250
- if (lookupA.kind === 'ok')
251
- expect(lookupA.session).toBe(sessionA);
252
- if (lookupB.kind === 'ok')
253
- expect(lookupB.session).toBe(sessionB);
254
- if (lookupC.kind === 'ok')
255
- expect(lookupC.session).toBe(sessionC);
256
- // Invariant 5: each session's page still reads its own per-URL
257
- // marker. This is the end-to-end "different browsers" check —
258
- // even though they share the same MCP server process, the
259
- // underlying Chromium / localStorage is distinct per session.
260
- const [markerA, markerB, markerC] = await Promise.all([
261
- waitForMarkerText(sessionA, 'marker-from-'),
262
- waitForMarkerText(sessionB, 'marker-from-'),
263
- waitForMarkerText(sessionC, 'marker-from-'),
264
- ]);
265
- expect(markerA).toBe('marker-from-/page-a');
266
- expect(markerB).toBe('marker-from-/page-b');
267
- expect(markerC).toBe('marker-from-/page-a');
268
- // Invariant 6: looking up a session id that no longer exists must
269
- // return `not_found` with the full active-id list, NEVER silently
270
- // fall back to the default. This is the v1.42-era contamination
271
- // vector: workers were seeing their tool calls routed to whatever
272
- // the "most recent" session happened to be.
273
- disconnect({ sessionId: sessionB.id, closeProxy: true });
274
- const afterDisconnect = resolveSession(sessionB.id);
275
- expect(afterDisconnect.kind).toBe('not_found');
276
- if (afterDisconnect.kind === 'not_found') {
277
- expect(afterDisconnect.id).toBe(sessionB.id);
278
- }
279
- // And the implicit default is STILL null — disconnecting one
280
- // isolated session doesn't promote the next one to default.
281
- expect(getDefaultSessionId()).toBeNull();
282
- }
283
- finally {
284
- disconnect({ sessionId: sessionA.id, closeProxy: true });
285
- disconnect({ sessionId: sessionC.id, closeProxy: true });
286
- }
287
- }, 60_000);
288
- it('a single non-isolated session DOES become the implicit default (back-compat)', async () => {
289
- // Single pooled session — the default-fallback behavior is retained
290
- // for callers that don't pass sessionId and aren't running in
291
- // parallel. This preserves every existing single-worker script.
292
- const session = await connectThroughProxy({
293
- pageUrl: `${baseUrl}/page-a`,
294
- headless: true,
295
- });
296
- try {
297
- expect(session.isolated).toBeFalsy();
298
- expect(getDefaultSessionId()).toBe(session.id);
299
- const resolved = resolveSession();
300
- expect(resolved.kind).toBe('ok');
301
- if (resolved.kind === 'ok')
302
- expect(resolved.session.id).toBe(session.id);
303
- }
304
- finally {
305
- disconnect({ sessionId: session.id, closeProxy: true });
306
- }
307
- }, 30_000);
308
- });
@@ -1 +0,0 @@
1
- export {};