@geometra/mcp 1.59.0 → 1.60.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/server.js +1 -75
- package/dist/session-state.js +2 -1
- package/dist/session.d.ts +1 -2
- package/dist/session.js +4 -25
- package/package.json +5 -3
- package/dist/__tests__/ats-integration.test.d.ts +0 -1
- package/dist/__tests__/ats-integration.test.js +0 -891
- package/dist/__tests__/connect-utils.test.d.ts +0 -1
- package/dist/__tests__/connect-utils.test.js +0 -255
- package/dist/__tests__/proxy-session-actions.test.d.ts +0 -1
- package/dist/__tests__/proxy-session-actions.test.js +0 -356
- package/dist/__tests__/proxy-session-recovery.test.d.ts +0 -1
- package/dist/__tests__/proxy-session-recovery.test.js +0 -262
- package/dist/__tests__/server-batch-results.test.d.ts +0 -1
- package/dist/__tests__/server-batch-results.test.js +0 -1777
- package/dist/__tests__/server-filters.test.d.ts +0 -1
- package/dist/__tests__/server-filters.test.js +0 -57
- package/dist/__tests__/server-session-resolution.test.d.ts +0 -1
- package/dist/__tests__/server-session-resolution.test.js +0 -88
- package/dist/__tests__/session-isolation.test.d.ts +0 -1
- package/dist/__tests__/session-isolation.test.js +0 -308
- package/dist/__tests__/session-model.test.d.ts +0 -1
- package/dist/__tests__/session-model.test.js +0 -815
- package/dist/__tests__/values-equivalent.test.d.ts +0 -1
- package/dist/__tests__/values-equivalent.test.js +0 -52
|
@@ -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 {};
|