mcp-accessibility-scanner 3.0.1 → 3.2.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/README.md +265 -12
- package/index.d.ts +1 -1
- package/lib/browserContextFactory.js +1161 -94
- package/lib/browserContextFactory.js.map +1 -1
- package/lib/browserServerBackend.js +199 -22
- package/lib/browserServerBackend.js.map +1 -1
- package/lib/browserSessions.js +172 -0
- package/lib/browserSessions.js.map +1 -0
- package/lib/config.js +48 -7
- package/lib/config.js.map +1 -1
- package/lib/context.js +321 -71
- package/lib/context.js.map +1 -1
- package/lib/extension/cdpRelay.js +5 -0
- package/lib/extension/cdpRelay.js.map +1 -1
- package/lib/extension/extensionContextFactory.js +12 -1
- package/lib/extension/extensionContextFactory.js.map +1 -1
- package/lib/index.js +5 -1
- package/lib/index.js.map +1 -1
- package/lib/mcp/http.js +194 -44
- package/lib/mcp/http.js.map +1 -1
- package/lib/mcp/inProcessTransport.js.map +1 -1
- package/lib/mcp/mdb.js +7 -9
- package/lib/mcp/mdb.js.map +1 -1
- package/lib/mcp/proxyBackend.js +76 -18
- package/lib/mcp/proxyBackend.js.map +1 -1
- package/lib/mcp/server.js +70 -41
- package/lib/mcp/server.js.map +1 -1
- package/lib/mcp/sharedClientSlot.js +134 -0
- package/lib/mcp/sharedClientSlot.js.map +1 -0
- package/lib/mcp/tool.js +3 -0
- package/lib/mcp/tool.js.map +1 -1
- package/lib/networkPolicy.js +73 -0
- package/lib/networkPolicy.js.map +1 -0
- package/lib/program.js +91 -12
- package/lib/program.js.map +1 -1
- package/lib/response.js +16 -3
- package/lib/response.js.map +1 -1
- package/lib/sessionLog.js +36 -6
- package/lib/sessionLog.js.map +1 -1
- package/lib/tab.js +149 -18
- package/lib/tab.js.map +1 -1
- package/lib/tools/auditKeyboard.js +204 -4
- package/lib/tools/auditKeyboard.js.map +1 -1
- package/lib/tools/auditScreenReader.js +823 -0
- package/lib/tools/auditScreenReader.js.map +1 -0
- package/lib/tools/auditSite.js +268 -90
- package/lib/tools/auditSite.js.map +1 -1
- package/lib/tools/axe.js +479 -21
- package/lib/tools/axe.js.map +1 -1
- package/lib/tools/dialogs.js +18 -4
- package/lib/tools/dialogs.js.map +1 -1
- package/lib/tools/evaluate.js +34 -5
- package/lib/tools/evaluate.js.map +1 -1
- package/lib/tools/network.js +136 -8
- package/lib/tools/network.js.map +1 -1
- package/lib/tools/pdf.js +5 -2
- package/lib/tools/pdf.js.map +1 -1
- package/lib/tools/scanPageMatrix.js +135 -47
- package/lib/tools/scanPageMatrix.js.map +1 -1
- package/lib/tools/screenshot.js +8 -4
- package/lib/tools/screenshot.js.map +1 -1
- package/lib/tools/session.js +53 -0
- package/lib/tools/session.js.map +1 -0
- package/lib/tools/snapshot.js +266 -8
- package/lib/tools/snapshot.js.map +1 -1
- package/lib/tools/tool.js.map +1 -1
- package/lib/tools/utils.js +62 -6
- package/lib/tools/utils.js.map +1 -1
- package/lib/tools.js +11 -2
- package/lib/tools.js.map +1 -1
- package/lib/utils/dataUrl.js +58 -35
- package/lib/utils/dataUrl.js.map +1 -1
- package/lib/utils/fileUtils.js +35 -0
- package/lib/utils/fileUtils.js.map +1 -1
- package/lib/utils/guid.js +8 -0
- package/lib/utils/guid.js.map +1 -1
- package/lib/utils/jsSource.js +187 -0
- package/lib/utils/jsSource.js.map +1 -0
- package/lib/vscode/browserContextFactory.js +87 -0
- package/lib/vscode/browserContextFactory.js.map +1 -0
- package/lib/vscode/host.js +189 -33
- package/lib/vscode/host.js.map +1 -1
- package/lib/vscode/main.js +3 -35
- package/lib/vscode/main.js.map +1 -1
- package/package.json +13 -9
|
@@ -22,9 +22,413 @@ import coreBundle from 'playwright-core/lib/coreBundle';
|
|
|
22
22
|
const { registryDirectory } = coreBundle.registry;
|
|
23
23
|
const { startTraceViewerServer } = coreBundle.server;
|
|
24
24
|
import { logUnhandledError, testDebug } from './utils/log.js';
|
|
25
|
-
import { createHash } from './utils/guid.js';
|
|
25
|
+
import { createGuid, createHash, createShortGuid } from './utils/guid.js';
|
|
26
26
|
import { outputFile } from './config.js';
|
|
27
|
+
import { ensureNetworkPolicyRoutes } from './networkPolicy.js';
|
|
28
|
+
/**
|
|
29
|
+
* Throws when a storage state is configured but `factory` will not apply it. A
|
|
30
|
+
* factory that neither creates a fresh context with the state nor applies it to
|
|
31
|
+
* the context it reuses would drop it without a word and audit the site as an
|
|
32
|
+
* anonymous user, which looks exactly like a successful run. Every factory in
|
|
33
|
+
* this file applies it one way or the other; the extension factory cannot — it
|
|
34
|
+
* works through the user's own running browser, where clearing every origin's
|
|
35
|
+
* cookies to install the recorded state is not an acceptable side effect.
|
|
36
|
+
* Callers pass the remedy that fits the mode they selected — the factory that
|
|
37
|
+
* creates the context is not always the one `contextFactory()` built.
|
|
38
|
+
*/
|
|
39
|
+
export function assertStorageStateSupported(config, factory, remedy) {
|
|
40
|
+
if (config.browser.contextOptions?.storageState && !factory.appliesStorageState)
|
|
41
|
+
throw new Error(`Storage state cannot be applied in this mode. ${remedy}`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Throws when the configured storage state would be installed inside a profile
|
|
45
|
+
* directory the user supplied. The profile carries its own session data — it is
|
|
46
|
+
* data this server does not own — and resetting it to the recorded state would
|
|
47
|
+
* destroy it. Every factory that lands the state in a context backed by
|
|
48
|
+
* `--user-data-dir` calls this before touching the browser; callers pass the
|
|
49
|
+
* remedy that fits their mode.
|
|
50
|
+
*/
|
|
51
|
+
export function assertStorageStateDoesNotResetUserProfile(config, remedy) {
|
|
52
|
+
if (config.browser.contextOptions?.storageState && config.browser.userDataDir)
|
|
53
|
+
throw new Error(`--storage-state and --user-data-dir contradict each other: the profile carries its own session data, and resetting a user-supplied profile to match the recorded state would destroy it. ${remedy}`);
|
|
54
|
+
}
|
|
27
55
|
export function contextFactory(config) {
|
|
56
|
+
const factory = createContextFactory(config);
|
|
57
|
+
// Every built-in factory now applies a storage state; the guard stays so a
|
|
58
|
+
// future factory that forgets to declare support rejects the option instead
|
|
59
|
+
// of silently dropping it.
|
|
60
|
+
assertStorageStateSupported(config, factory, 'Drop the storage state and sign in interactively before auditing.');
|
|
61
|
+
return factory;
|
|
62
|
+
}
|
|
63
|
+
// The rules addCookies enforces client-side (verified against Playwright
|
|
64
|
+
// 1.61.1: empty or missing domain/path without a url, a url combined with a
|
|
65
|
+
// domain or a path, about:blank/data:/unparseable urls, an expires other
|
|
66
|
+
// than -1 or a positive number up to Playwright's ceiling, and sameSite
|
|
67
|
+
// outside Strict/Lax/None are all rejected there — non-http(s) url schemes
|
|
68
|
+
// and malformed origin strings fail browser-side during the apply). Failing
|
|
69
|
+
// them here keeps the failure ahead of the cache clear; anything these
|
|
70
|
+
// checks miss still fails inside setStorageState.
|
|
71
|
+
const cookieUrlProblem = (url) => {
|
|
72
|
+
if (typeof url !== 'string')
|
|
73
|
+
return 'is not a string';
|
|
74
|
+
if (url === 'about:blank')
|
|
75
|
+
return 'cannot be about:blank';
|
|
76
|
+
if (url.startsWith('data:'))
|
|
77
|
+
return 'cannot be a data: URL';
|
|
78
|
+
try {
|
|
79
|
+
const parsed = new URL(url);
|
|
80
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
81
|
+
return `must be an http(s) URL, not ${parsed.protocol}`;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return 'is not a valid absolute URL';
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
const isValidIndexedDBKey = (value) => typeof value === 'string'
|
|
89
|
+
|| (typeof value === 'number' && Number.isFinite(value))
|
|
90
|
+
|| (value instanceof Date && Number.isFinite(value.getTime()))
|
|
91
|
+
|| value instanceof ArrayBuffer
|
|
92
|
+
|| ArrayBuffer.isView(value)
|
|
93
|
+
|| (Array.isArray(value) && value.every(isValidIndexedDBKey));
|
|
94
|
+
const indexedDBIdentifier = /^[$_\p{ID_Start}][$\u200C\u200D\p{ID_Continue}]*$/u;
|
|
95
|
+
const isValidIndexedDBKeyPath = (value) => typeof value === 'string' && (value === '' || value.split('.').every(part => indexedDBIdentifier.test(part)));
|
|
96
|
+
const isValidIndexedDBKeyPathArray = (value) => Array.isArray(value) && !!value.length && value.every(isValidIndexedDBKeyPath);
|
|
97
|
+
function assertValidStorageState(state) {
|
|
98
|
+
for (const value of state.cookies ?? []) {
|
|
99
|
+
const cookie = value;
|
|
100
|
+
const problem = !cookie || typeof cookie !== 'object'
|
|
101
|
+
? 'a cookie entry is not an object'
|
|
102
|
+
: !cookie.url && (!cookie.domain || !cookie.path)
|
|
103
|
+
? `cookie "${String(cookie.name ?? '')}" should have a url or a domain/path pair`
|
|
104
|
+
: cookie.url && cookie.domain
|
|
105
|
+
? `cookie "${String(cookie.name ?? '')}" should have either a url or a domain, not both`
|
|
106
|
+
: cookie.url && cookie.path
|
|
107
|
+
? `cookie "${String(cookie.name ?? '')}" should have either a url or a path, not both`
|
|
108
|
+
: cookie.url !== undefined && cookieUrlProblem(cookie.url)
|
|
109
|
+
? `cookie "${String(cookie.name ?? '')}" has a url that ${cookieUrlProblem(cookie.url)}`
|
|
110
|
+
: cookie.expires !== undefined && (typeof cookie.expires !== 'number' || Number.isNaN(cookie.expires) || (cookie.expires !== -1 && (cookie.expires <= 0 || cookie.expires > 253402300799)))
|
|
111
|
+
? `cookie "${String(cookie.name ?? '')}" should have a valid expires — only -1 or a positive unix timestamp in seconds up to 253402300799 (9999-12-31T23:59:59Z, Playwright's own ceiling) is allowed`
|
|
112
|
+
: cookie.sameSite !== undefined && !['Strict', 'Lax', 'None'].includes(cookie.sameSite)
|
|
113
|
+
? `cookie "${String(cookie.name ?? '')}" has sameSite "${String(cookie.sameSite)}", expected one of Strict|Lax|None`
|
|
114
|
+
: null;
|
|
115
|
+
if (problem)
|
|
116
|
+
throw new Error(`Invalid storage state: ${problem}. Nothing was changed — the state is validated before the apply, because setStorageState() clears the attached context's HTTP cache and cookie jar before it validates, and the cache cannot be restored.`);
|
|
117
|
+
}
|
|
118
|
+
for (const value of state.origins ?? []) {
|
|
119
|
+
const entry = value;
|
|
120
|
+
// Restoring an origin's storage navigates Playwright's temporary page to
|
|
121
|
+
// it — a malformed or non-http(s) origin fails that navigation after the
|
|
122
|
+
// clear.
|
|
123
|
+
const problem = !entry || typeof entry !== 'object'
|
|
124
|
+
? 'an origins entry is not an object'
|
|
125
|
+
: typeof entry.origin !== 'string' || cookieUrlProblem(entry.origin)
|
|
126
|
+
? `origins entry "${String(entry?.origin ?? '')}" is not an absolute http(s) URL`
|
|
127
|
+
: null;
|
|
128
|
+
if (problem)
|
|
129
|
+
throw new Error(`Invalid storage state: ${problem}. Nothing was changed — the state is validated before the apply, because setStorageState() clears the attached context's HTTP cache and cookie jar before it validates, and the cache cannot be restored.`);
|
|
130
|
+
const databaseNames = new Set();
|
|
131
|
+
for (const value of Array.isArray(entry?.indexedDB) ? entry.indexedDB : []) {
|
|
132
|
+
const database = value;
|
|
133
|
+
const stores = Array.isArray(database.stores) ? database.stores : [];
|
|
134
|
+
let indexedDBProblem = !Number.isSafeInteger(database.version) || database.version <= 0
|
|
135
|
+
? `IndexedDB database "${String(database.name ?? '')}" should have a positive integer version`
|
|
136
|
+
: databaseNames.has(String(database.name))
|
|
137
|
+
? `IndexedDB database name "${String(database.name)}" is duplicated`
|
|
138
|
+
: null;
|
|
139
|
+
databaseNames.add(String(database.name));
|
|
140
|
+
const storeNames = new Set();
|
|
141
|
+
for (const store of stores) {
|
|
142
|
+
const storeName = String(store.name);
|
|
143
|
+
indexedDBProblem ??= storeNames.has(storeName)
|
|
144
|
+
? `IndexedDB object store name "${storeName}" is duplicated in database "${String(database.name)}"`
|
|
145
|
+
: store.keyPath !== undefined && !isValidIndexedDBKeyPath(store.keyPath)
|
|
146
|
+
? `IndexedDB object store "${storeName}" has an invalid key path`
|
|
147
|
+
: store.keyPathArray !== undefined && !isValidIndexedDBKeyPathArray(store.keyPathArray)
|
|
148
|
+
? `IndexedDB object store "${storeName}" has an invalid array key path`
|
|
149
|
+
: store.autoIncrement && (store.keyPath === '' || Array.isArray(store.keyPathArray))
|
|
150
|
+
? `IndexedDB object store "${storeName}" cannot combine autoIncrement with an empty or array key path`
|
|
151
|
+
: null;
|
|
152
|
+
storeNames.add(storeName);
|
|
153
|
+
const recordKeys = new Set();
|
|
154
|
+
const hasInlineKey = store.keyPath !== undefined || store.keyPathArray !== undefined;
|
|
155
|
+
for (const value of Array.isArray(store.records) ? store.records : []) {
|
|
156
|
+
const record = value;
|
|
157
|
+
const hasExternalKey = (record.key !== undefined && record.key !== null)
|
|
158
|
+
|| (record.keyEncoded !== undefined && record.keyEncoded !== null);
|
|
159
|
+
const key = record.key ?? record.keyEncoded;
|
|
160
|
+
const serializedKey = hasExternalKey ? JSON.stringify(key) ?? String(key) : '';
|
|
161
|
+
indexedDBProblem ??= record.key !== undefined && record.key !== null && !isValidIndexedDBKey(record.key)
|
|
162
|
+
? `IndexedDB object store "${storeName}" has an invalid external record key`
|
|
163
|
+
: hasInlineKey && hasExternalKey
|
|
164
|
+
? `IndexedDB object store "${storeName}" has an inline key path but record also supplies an external key`
|
|
165
|
+
: !hasInlineKey && !store.autoIncrement && !hasExternalKey
|
|
166
|
+
? `IndexedDB object store "${storeName}" requires an external key for every record`
|
|
167
|
+
: hasExternalKey && recordKeys.has(serializedKey)
|
|
168
|
+
? `IndexedDB object store "${storeName}" has duplicate record key ${serializedKey}`
|
|
169
|
+
: null;
|
|
170
|
+
if (hasExternalKey)
|
|
171
|
+
recordKeys.add(serializedKey);
|
|
172
|
+
}
|
|
173
|
+
const indexNames = new Set();
|
|
174
|
+
for (const index of Array.isArray(store.indexes) ? store.indexes : []) {
|
|
175
|
+
const indexName = String(index.name);
|
|
176
|
+
indexedDBProblem ??= indexNames.has(indexName)
|
|
177
|
+
? `IndexedDB index name "${indexName}" is duplicated in object store "${storeName}"`
|
|
178
|
+
: index.keyPath !== undefined && !isValidIndexedDBKeyPath(index.keyPath)
|
|
179
|
+
? `IndexedDB index "${indexName}" has an invalid key path`
|
|
180
|
+
: index.keyPathArray !== undefined && !isValidIndexedDBKeyPathArray(index.keyPathArray)
|
|
181
|
+
? `IndexedDB index "${indexName}" has an invalid array key path`
|
|
182
|
+
: index.multiEntry && Array.isArray(index.keyPathArray)
|
|
183
|
+
? `IndexedDB index "${indexName}" cannot combine multiEntry with an array key path`
|
|
184
|
+
: null;
|
|
185
|
+
indexNames.add(indexName);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (indexedDBProblem)
|
|
189
|
+
throw new Error(`Invalid storage state: ${indexedDBProblem}. Nothing was changed — the state is validated before the apply, because setStorageState() clears the attached context's HTTP cache and cookie jar before it validates, and the cache cannot be restored.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Replaces every open page of `browserContext` with a blank fresh tab and
|
|
195
|
+
* returns the fresh tabs paired with the URL each replaced page showed.
|
|
196
|
+
* Replacement, not an in-page sessionStorage clear plus reload: the old
|
|
197
|
+
* document's scripts keep running between an evaluated clear and the
|
|
198
|
+
* navigation that would follow it, and a timer persisting the previous
|
|
199
|
+
* identity can write it back into that window — sessionStorage survives the
|
|
200
|
+
* reload, and the audit reads the old user again. A fresh tab starts with
|
|
201
|
+
* empty sessionStorage for every origin (only window.open clones the
|
|
202
|
+
* opener's copy, verified against Playwright 1.61.1 Chromium). The
|
|
203
|
+
* replacement tab is created before the old page closes — closing a
|
|
204
|
+
* browser's last tab can take the whole (attached, user-owned) browser down
|
|
205
|
+
* with it.
|
|
206
|
+
*/
|
|
207
|
+
async function replaceOpenPagesWithBlankTabs(browserContext) {
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
const arrivals = [];
|
|
210
|
+
const onPage = (page) => arrivals.push(page);
|
|
211
|
+
const replaced = [];
|
|
212
|
+
const replacePage = async (page) => {
|
|
213
|
+
const url = page.url();
|
|
214
|
+
let fresh;
|
|
215
|
+
try {
|
|
216
|
+
fresh = await browserContext.newPage();
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Without a replacement tab (Electron targets cannot create pages),
|
|
220
|
+
// closing is the only way to keep the previous identity's DOM and
|
|
221
|
+
// sessionStorage out of the audit.
|
|
222
|
+
await page.close();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// The replacement never needs replacing itself — it must not be swept up
|
|
226
|
+
// by the loop below when it surfaces through pages() or the listener.
|
|
227
|
+
seen.add(fresh);
|
|
228
|
+
try {
|
|
229
|
+
await page.close();
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
await fresh.close().catch(() => { });
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
replaced.push({ page: fresh, url });
|
|
236
|
+
};
|
|
237
|
+
// A still-old document can open a page while the ones above are being
|
|
238
|
+
// replaced — a popup from a timer, say — and a same-origin popup clones
|
|
239
|
+
// its opener's previous-identity sessionStorage at creation. A single
|
|
240
|
+
// pages() snapshot would hand such a page to Context unreset, so the sweep
|
|
241
|
+
// repeats until no unseen page remains; the temporary 'page' listener
|
|
242
|
+
// catches pages whose creation the next pages() call cannot see yet. The
|
|
243
|
+
// Set dedupes a page that arrives through both — two concurrent
|
|
244
|
+
// replacements of one page would race each other's close.
|
|
245
|
+
browserContext.on('page', onPage);
|
|
246
|
+
try {
|
|
247
|
+
while (true) {
|
|
248
|
+
const pending = [...new Set([...browserContext.pages(), ...arrivals.splice(0)])].filter(page => !seen.has(page));
|
|
249
|
+
if (!pending.length)
|
|
250
|
+
break;
|
|
251
|
+
for (const page of pending)
|
|
252
|
+
seen.add(page);
|
|
253
|
+
await Promise.all(pending.map(replacePage));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
browserContext.off('page', onPage);
|
|
258
|
+
}
|
|
259
|
+
return replaced;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Navigates each replacement tab to the URL its replaced page showed, bringing
|
|
263
|
+
* what a scan sees onto the state the context now holds. A tab that cannot
|
|
264
|
+
* load its page — the origin may be blocked by the just-installed policy, or
|
|
265
|
+
* the load simply failed — is blanked instead (the fresh tab carries no old
|
|
266
|
+
* identity, so a blank one is safe to hand to Context), and closed only when
|
|
267
|
+
* even blanking fails.
|
|
268
|
+
*/
|
|
269
|
+
async function navigateReplacementPages(replaced) {
|
|
270
|
+
await Promise.all(replaced.map(async ({ page, url }) => {
|
|
271
|
+
if (!url || url === 'about:blank')
|
|
272
|
+
return;
|
|
273
|
+
try {
|
|
274
|
+
await page.goto(url);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
try {
|
|
278
|
+
await page.goto('about:blank');
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
await page.close().catch(() => { });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Lands the configured storage state in a context the browser already had.
|
|
288
|
+
* `setStorageState()` clears the context's cookies, local storage and IndexedDB
|
|
289
|
+
* and installs the recorded state — the documented semantics of the option the
|
|
290
|
+
* caller asked for, applied to a context `newContext()` never sees: the CDP
|
|
291
|
+
* modes without --isolated reuse the browser's existing context, and
|
|
292
|
+
* launchPersistentContext() silently ignores a storageState option (verified
|
|
293
|
+
* against Playwright 1.61.1).
|
|
294
|
+
*/
|
|
295
|
+
export async function applyStorageStateToReusedContext(config, browserContext) {
|
|
296
|
+
const storageState = config.browser.contextOptions?.storageState;
|
|
297
|
+
if (!storageState)
|
|
298
|
+
return;
|
|
299
|
+
const parsedState = await (async () => {
|
|
300
|
+
try {
|
|
301
|
+
return typeof storageState === 'string'
|
|
302
|
+
? JSON.parse(await fs.promises.readFile(storageState, 'utf-8'))
|
|
303
|
+
: storageState;
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
// Letting setStorageState() discover the bad file would fail inside
|
|
307
|
+
// the apply block, whose catch answers every failure with a rollback —
|
|
308
|
+
// and the rollback's own setStorageState() clears the attached
|
|
309
|
+
// context's HTTP cache. A config error that changed nothing must not
|
|
310
|
+
// cost the running application its cache.
|
|
311
|
+
throw new Error(`The storage state file could not be read or parsed: ${error instanceof Error ? error.message : String(error)}. Nothing was changed.`);
|
|
312
|
+
}
|
|
313
|
+
})();
|
|
314
|
+
// Playwright validates cookies only while installing them — after the
|
|
315
|
+
// attached context's HTTP cache and cookie jar are already cleared — so a
|
|
316
|
+
// semantically invalid cookie (bad expires, missing domain/path) would
|
|
317
|
+
// fail the apply with the cache unrestorably gone. Checked up front, with
|
|
318
|
+
// the same rules addCookies enforces (verified against 1.61.1).
|
|
319
|
+
assertValidStorageState(parsedState);
|
|
320
|
+
// setStorageState needs a temporary page whenever the state carries origins
|
|
321
|
+
// or the context has visited any — and by the time that page creation fails
|
|
322
|
+
// on a target without Target.createTarget, the HTTP cache is already cleared
|
|
323
|
+
// and cannot be put back. Probe the page creation first, so such targets are
|
|
324
|
+
// rejected before anything is mutated. When neither signal indicates a page
|
|
325
|
+
// will be needed (cookie-only state, no pages open), the probe is skipped so
|
|
326
|
+
// that case keeps working on those targets.
|
|
327
|
+
const stateHasOrigins = (parsedState.origins?.length ?? 0) > 0;
|
|
328
|
+
const hasLoadedPages = browserContext.pages().some(page => page.url() && page.url() !== 'about:blank');
|
|
329
|
+
if (stateHasOrigins || hasLoadedPages) {
|
|
330
|
+
try {
|
|
331
|
+
const probe = await browserContext.newPage();
|
|
332
|
+
await probe.close();
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
336
|
+
if (message.includes('Target.createTarget'))
|
|
337
|
+
throw new Error(`The attached browser cannot open the temporary page Playwright needs to apply the storage state's origin data (Electron targets do not support Target.createTarget). Nothing was changed. Drop the storage state and sign in inside the app instead. Original error: ${message}`);
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// setStorageState replaces the cookie jar and then rewrites origin storage
|
|
342
|
+
// one origin at a time, so a failure partway would otherwise leave the
|
|
343
|
+
// attached browser holding a mixture of old and recorded state while the
|
|
344
|
+
// operation reports failure. Both layers are snapshotted first: the cookie
|
|
345
|
+
// jar through pure protocol calls that work everywhere (kept as the
|
|
346
|
+
// fallback for a restore whose own origin phase fails), and origin storage
|
|
347
|
+
// through storageState() below.
|
|
348
|
+
const originalCookies = await browserContext.cookies();
|
|
349
|
+
// The snapshot doubles as a probe for origins this connection has already
|
|
350
|
+
// visited while its pages have since closed or gone blank: for those,
|
|
351
|
+
// storageState() opens the same temporary page the forward apply will need
|
|
352
|
+
// — the newPage probe above cannot see them — but unlike setStorageState()
|
|
353
|
+
// it mutates nothing, so a target that cannot create pages (Electron) is
|
|
354
|
+
// rejected here with everything intact, not after the forward apply has
|
|
355
|
+
// cleared the HTTP cache. Any other snapshot failure also aborts: without
|
|
356
|
+
// the full snapshot, a partial forward apply could only be rolled back to
|
|
357
|
+
// cookies, leaving the attached browser's origin storage part old, part
|
|
358
|
+
// recorded.
|
|
359
|
+
const originalState = await browserContext.storageState({ indexedDB: true }).catch((error) => {
|
|
360
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
361
|
+
if (message.includes('Target.createTarget'))
|
|
362
|
+
throw new Error(`The attached browser cannot open the temporary page Playwright needs to reset origin storage for origins this connection has already visited (Electron targets do not support Target.createTarget). Nothing was changed. Drop the storage state and sign in inside the app instead. Original error: ${message}`);
|
|
363
|
+
throw new Error(`Snapshotting the context's current storage for rollback failed, so the storage state was not applied — a partial apply could not have been undone. Nothing was changed. Retry, or use --isolated for a fresh context. Original error: ${message}`);
|
|
364
|
+
});
|
|
365
|
+
// Pages that were already open still render the previous identity — and
|
|
366
|
+
// their scripts keep running: a page that periodically persists
|
|
367
|
+
// authentication into cookies or localStorage would overwrite the state
|
|
368
|
+
// being installed if it were still alive during setStorageState(), and
|
|
369
|
+
// replacing its tab afterwards cannot undo writes already made into
|
|
370
|
+
// context-wide storage. Every open page is therefore replaced with a blank
|
|
371
|
+
// fresh tab FIRST — the old document closes before the state lands, and
|
|
372
|
+
// only blank replacements (which run no scripts) survive the apply — and
|
|
373
|
+
// the replacements are navigated to the pages they replaced only once the
|
|
374
|
+
// recorded state is in place.
|
|
375
|
+
const replaced = await replaceOpenPagesWithBlankTabs(browserContext);
|
|
376
|
+
const policyRequired = !!(config.network?.allowedOrigins?.length || config.network?.blockedOrigins?.length);
|
|
377
|
+
let replacementNavigationSafe = !policyRequired;
|
|
378
|
+
try {
|
|
379
|
+
// The state validated above is the state applied: handing the path back
|
|
380
|
+
// to Playwright would re-read the file here, and a file replaced since
|
|
381
|
+
// that read would skip the cookie/origin validation and the page-creation
|
|
382
|
+
// probe only to fail after the cache clear those exist to prevent.
|
|
383
|
+
await browserContext.setStorageState(parsedState);
|
|
384
|
+
// The replacement navigations run inside the factory, before Context
|
|
385
|
+
// ensures the configured origin allowlist/blocklist — and the recorded
|
|
386
|
+
// credentials are already in place by now. The policy is installed here
|
|
387
|
+
// (permanently — page scripts can queue requests that fire after the
|
|
388
|
+
// navigation settles, so removing the handlers before Context re-ensures
|
|
389
|
+
// the same policy would open a window to a blocked origin;
|
|
390
|
+
// ensureNetworkPolicyRoutes installs once per context, so Context's later
|
|
391
|
+
// call is a no-op). Installed after setStorageState() so an abort-all
|
|
392
|
+
// route cannot interfere with the temporary page Playwright drives to
|
|
393
|
+
// restore origin storage.
|
|
394
|
+
await ensureNetworkPolicyRoutes(config, browserContext);
|
|
395
|
+
replacementNavigationSafe = true;
|
|
396
|
+
await navigateReplacementPages(replaced);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
// Prefer the full-state rollback; fall back to cookies-only when its
|
|
400
|
+
// reapplication is itself impossible on this target.
|
|
401
|
+
const restoredFully = await browserContext.setStorageState(originalState).then(() => true, () => false);
|
|
402
|
+
const restoredCookies = restoredFully || await browserContext.clearCookies()
|
|
403
|
+
.then(() => originalCookies.length ? browserContext.addCookies(originalCookies) : undefined)
|
|
404
|
+
.then(() => true, () => false);
|
|
405
|
+
// The old pages were closed before the apply and cannot be handed back.
|
|
406
|
+
// Navigate their replacements only when no policy was required or its
|
|
407
|
+
// installation succeeded; otherwise restored credentials stay offline.
|
|
408
|
+
if (replacementNavigationSafe)
|
|
409
|
+
await navigateReplacementPages(replaced);
|
|
410
|
+
else
|
|
411
|
+
await Promise.all(replaced.map(({ page }) => page.close().catch(() => { })));
|
|
412
|
+
// Restoring origin storage (localStorage/IndexedDB) makes Playwright open a
|
|
413
|
+
// temporary page; a CDP target that cannot create one — Electron has no
|
|
414
|
+
// Target.createTarget — fails here. Cookie-only states need no page and
|
|
415
|
+
// still work on such targets, so name that remedy instead of surfacing the
|
|
416
|
+
// raw protocol error.
|
|
417
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
418
|
+
if (message.includes('Target.createTarget')) {
|
|
419
|
+
const rollbackNote = restoredFully
|
|
420
|
+
? 'The context\'s original storage state was restored.'
|
|
421
|
+
: restoredCookies
|
|
422
|
+
? 'The context\'s original cookies were restored.'
|
|
423
|
+
: 'Restoring the context\'s original cookies also failed; its cookie jar may now hold the recorded state.';
|
|
424
|
+
throw new Error(`The attached browser cannot open the temporary page Playwright needs to reset origin storage (Electron targets do not support Target.createTarget). Drop the storage state and sign in inside the app instead — a cookies-only state helps only while the attached target has no pages open and the connection has visited no origin, because clearing storage for an already-visited origin needs the same temporary page. ${rollbackNote} Original error: ${message}`);
|
|
425
|
+
}
|
|
426
|
+
if (!restoredFully && restoredCookies)
|
|
427
|
+
throw new Error(`${message} The context's original cookies were restored, but origin storage may retain partially applied state.`);
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function createContextFactory(config) {
|
|
28
432
|
if (config.browser.remoteEndpoint)
|
|
29
433
|
return new RemoteContextFactory(config);
|
|
30
434
|
if (config.browser.cdpLaunch)
|
|
@@ -39,76 +443,272 @@ class BaseContextFactory {
|
|
|
39
443
|
config;
|
|
40
444
|
_logName;
|
|
41
445
|
_browserPromise;
|
|
446
|
+
// Counts live handouts per browser object, claimed BEFORE awaiting context
|
|
447
|
+
// creation — the same pattern as CdpContextFactory. A `browser.contexts()`
|
|
448
|
+
// census cannot see a sibling still inside _doCreateContext(): if session
|
|
449
|
+
// A's close ran while session B's first newContext() was in flight, A saw
|
|
450
|
+
// itself as the last context and closed the shared browser out from under
|
|
451
|
+
// B. Keyed per browser object (not per factory) because an external
|
|
452
|
+
// disconnect makes _obtainBrowser hand out a fresh browser while stale
|
|
453
|
+
// handouts still reference the old one.
|
|
454
|
+
_handoutCounts = new WeakMap();
|
|
455
|
+
// Acquisitions that have entered createContext() but not yet claimed their
|
|
456
|
+
// per-browser handout count: `await` yields to the microtask queue even on
|
|
457
|
+
// an already-resolved browser promise, so a sibling's close running inside
|
|
458
|
+
// that window used to see zero remaining handouts and shut the shared
|
|
459
|
+
// browser down under the resuming caller. Registered synchronously before
|
|
460
|
+
// the first await (see _acquireBrowser) and consulted by every "am I the
|
|
461
|
+
// last one?" check. Keyed per obtain promise, not per factory: a pending
|
|
462
|
+
// acquisition on a NEW promise (after an external disconnect evicted the
|
|
463
|
+
// old one) must not keep the old browser from closing.
|
|
464
|
+
_pendingAcquisitions = new Map();
|
|
42
465
|
constructor(name, config) {
|
|
43
466
|
this._logName = name;
|
|
44
467
|
this.config = config;
|
|
45
468
|
}
|
|
46
|
-
async
|
|
469
|
+
// Deliberately not async: the returned promise must be the cached
|
|
470
|
+
// `_browserPromise` itself so callers can register pending acquisitions
|
|
471
|
+
// against it and use it for the identity guards, and the body must run
|
|
472
|
+
// synchronously so obtaining and registering happen in one continuation.
|
|
473
|
+
_obtainBrowser(clientInfo) {
|
|
47
474
|
if (this._browserPromise)
|
|
48
475
|
return this._browserPromise;
|
|
49
476
|
testDebug(`obtain browser (${this._logName})`);
|
|
50
|
-
|
|
51
|
-
|
|
477
|
+
const promise = this._doObtainBrowser(clientInfo);
|
|
478
|
+
this._browserPromise = promise;
|
|
479
|
+
// The eviction is bound to the promise this browser came from — the same
|
|
480
|
+
// identity guard the close paths use: a close evicts the cache eagerly,
|
|
481
|
+
// so by the time the closing browser's asynchronous 'disconnected' fires
|
|
482
|
+
// (or a failed obtain rejects), a successor connection may already be
|
|
483
|
+
// cached, and clearing it would churn yet another browser for the next
|
|
484
|
+
// session while the successor is alive.
|
|
485
|
+
void promise.then(browser => {
|
|
52
486
|
browser.on('disconnected', () => {
|
|
53
|
-
this._browserPromise
|
|
487
|
+
if (this._browserPromise === promise)
|
|
488
|
+
this._browserPromise = undefined;
|
|
54
489
|
});
|
|
55
490
|
}).catch(() => {
|
|
56
|
-
this._browserPromise
|
|
491
|
+
if (this._browserPromise === promise)
|
|
492
|
+
this._browserPromise = undefined;
|
|
57
493
|
});
|
|
58
|
-
return
|
|
494
|
+
return promise;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Obtains the shared browser and claims the caller's per-browser count via
|
|
498
|
+
* `claim`, atomically with the browser's delivery: the acquisition is
|
|
499
|
+
* registered in a synchronous counter before the first await, and `claim`
|
|
500
|
+
* runs in the same continuation that resolves the browser, so at every
|
|
501
|
+
* point the caller is visible either as pending or as a live handout. The
|
|
502
|
+
* close paths consult _hasPendingAcquisition() and defer the browser
|
|
503
|
+
* shutdown to a pending acquisition instead of treating themselves as last.
|
|
504
|
+
*/
|
|
505
|
+
async _acquireBrowser(clientInfo, claim) {
|
|
506
|
+
const obtainedPromise = this._obtainBrowser(clientInfo);
|
|
507
|
+
this._pendingAcquisitions.set(obtainedPromise, (this._pendingAcquisitions.get(obtainedPromise) ?? 0) + 1);
|
|
508
|
+
try {
|
|
509
|
+
const browser = await obtainedPromise;
|
|
510
|
+
claim(browser);
|
|
511
|
+
return { browser, obtainedPromise };
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
const pending = (this._pendingAcquisitions.get(obtainedPromise) ?? 1) - 1;
|
|
515
|
+
if (pending > 0)
|
|
516
|
+
this._pendingAcquisitions.set(obtainedPromise, pending);
|
|
517
|
+
else
|
|
518
|
+
this._pendingAcquisitions.delete(obtainedPromise);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* True while a createContext() has started against `obtainedPromise` but
|
|
523
|
+
* not yet claimed its per-browser count. A release that would otherwise be
|
|
524
|
+
* the last defers the browser shutdown to that acquisition — which either
|
|
525
|
+
* claims the count in the same continuation the promise resolves in (its
|
|
526
|
+
* own release then closes the browser), or fails to obtain the browser
|
|
527
|
+
* altogether, in which case there is no browser left to close (a rejected
|
|
528
|
+
* obtain never launched one).
|
|
529
|
+
*/
|
|
530
|
+
_hasPendingAcquisition(obtainedPromise) {
|
|
531
|
+
return !!this._pendingAcquisitions.get(obtainedPromise);
|
|
532
|
+
}
|
|
533
|
+
_releaseHandout(browser) {
|
|
534
|
+
const remaining = Math.max(0, (this._handoutCounts.get(browser) ?? 1) - 1);
|
|
535
|
+
this._handoutCounts.set(browser, remaining);
|
|
536
|
+
return remaining === 0;
|
|
59
537
|
}
|
|
60
538
|
async createContext(clientInfo) {
|
|
61
539
|
testDebug(`create browser context (${this._logName})`);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
testDebug(`close browser (${this._logName})`);
|
|
73
|
-
await browser.close().catch(logUnhandledError);
|
|
540
|
+
// `obtainedPromise` is the promise this browser came from — it guards the
|
|
541
|
+
// eager `_browserPromise` resets below: after an external disconnect a
|
|
542
|
+
// NEW promise may be in place, and clearing it would orphan the fresh
|
|
543
|
+
// connection other sessions are about to use.
|
|
544
|
+
const { browser, obtainedPromise } = await this._acquireBrowser(clientInfo, acquired => {
|
|
545
|
+
this._handoutCounts.set(acquired, (this._handoutCounts.get(acquired) ?? 0) + 1);
|
|
546
|
+
});
|
|
547
|
+
let browserContext;
|
|
548
|
+
try {
|
|
549
|
+
browserContext = await this._doCreateContext(browser);
|
|
74
550
|
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
// The handout never materialized. When it was the last one, the browser
|
|
553
|
+
// must not stay behind ownerless — a sibling's close may have deferred
|
|
554
|
+
// the browser shutdown to this in-flight creation.
|
|
555
|
+
if (this._releaseHandout(browser) && !this._hasPendingAcquisition(obtainedPromise)) {
|
|
556
|
+
if (this._browserPromise === obtainedPromise)
|
|
557
|
+
this._browserPromise = undefined;
|
|
558
|
+
testDebug(`close browser (${this._logName})`);
|
|
559
|
+
await browser.close().catch(logUnhandledError);
|
|
560
|
+
}
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
let released = false;
|
|
564
|
+
return {
|
|
565
|
+
browserContext,
|
|
566
|
+
close: async () => {
|
|
567
|
+
if (released)
|
|
568
|
+
return;
|
|
569
|
+
released = true;
|
|
570
|
+
testDebug(`close browser context (${this._logName})`);
|
|
571
|
+
const last = this._releaseHandout(browser) && !this._hasPendingAcquisition(obtainedPromise);
|
|
572
|
+
// Cleared before the awaits so a createContext() arriving while this
|
|
573
|
+
// close is still in flight obtains a fresh browser instead of the
|
|
574
|
+
// closing one.
|
|
575
|
+
if (last && this._browserPromise === obtainedPromise)
|
|
576
|
+
this._browserPromise = undefined;
|
|
577
|
+
await browserContext.close().catch(logUnhandledError);
|
|
578
|
+
if (last) {
|
|
579
|
+
testDebug(`close browser (${this._logName})`);
|
|
580
|
+
await browser.close().catch(logUnhandledError);
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
};
|
|
75
584
|
}
|
|
76
585
|
}
|
|
77
586
|
class IsolatedContextFactory extends BaseContextFactory {
|
|
587
|
+
appliesStorageState = true;
|
|
78
588
|
constructor(config) {
|
|
79
589
|
super('isolated', config);
|
|
80
590
|
}
|
|
81
591
|
async _doObtainBrowser(clientInfo) {
|
|
82
|
-
await
|
|
592
|
+
const { cdpPortOptions, releaseCdpPort } = await allocateCdpPort(this.config.browser);
|
|
83
593
|
const browserType = playwright[this.config.browser.browserName];
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
594
|
+
try {
|
|
595
|
+
return await browserType.launch({
|
|
596
|
+
tracesDir: await startTraceServer(this.config),
|
|
597
|
+
...this.config.browser.launchOptions,
|
|
598
|
+
...cdpPortOptions,
|
|
599
|
+
handleSIGINT: false,
|
|
600
|
+
handleSIGTERM: false,
|
|
601
|
+
}).catch(error => {
|
|
602
|
+
if (error.message.includes('Executable doesn\'t exist'))
|
|
603
|
+
throw browserNotInstalledError(error);
|
|
604
|
+
throw error;
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
// Bound by the launched browser on success, free for reuse on failure —
|
|
609
|
+
// either way the reservation has served its purpose.
|
|
610
|
+
releaseCdpPort();
|
|
611
|
+
}
|
|
94
612
|
}
|
|
95
613
|
async _doCreateContext(browser) {
|
|
96
614
|
return browser.newContext(this.config.browser.contextOptions);
|
|
97
615
|
}
|
|
98
616
|
}
|
|
99
617
|
class CdpContextFactory extends BaseContextFactory {
|
|
618
|
+
// The isolated path creates a fresh context with the state; the attach path
|
|
619
|
+
// applies it to the browser's existing context via setStorageState().
|
|
620
|
+
appliesStorageState = true;
|
|
621
|
+
// One attached browser — and its default context — serves every session
|
|
622
|
+
// this factory creates. Re-running the global setStorageState() for a
|
|
623
|
+
// second session would wipe the first session's live cookies and origin
|
|
624
|
+
// storage mid-audit and reload its pages, so the state is applied once per
|
|
625
|
+
// context object: later sessions join the live shared state. Keyed weakly —
|
|
626
|
+
// a reconnect yields a fresh context object, so the slate resets with the
|
|
627
|
+
// connection — and a failed apply is forgotten so the next session retries.
|
|
628
|
+
_storageStateApplied = new WeakMap();
|
|
629
|
+
// Serializes the no-context fallback the same way: two sessions arriving at
|
|
630
|
+
// a contextless target must share one created context, not race two.
|
|
631
|
+
_fallbackContext = new WeakMap();
|
|
100
632
|
constructor(config) {
|
|
101
633
|
super('cdp', config);
|
|
102
634
|
}
|
|
635
|
+
// Without --isolated every session gets the attached browser's one existing
|
|
636
|
+
// context, so a "separate" browser session would share its tabs, cookies
|
|
637
|
+
// and storage with everything else.
|
|
638
|
+
get sessionsUnsupportedReason() {
|
|
639
|
+
if (this.config.browser.isolated)
|
|
640
|
+
return undefined;
|
|
641
|
+
return 'this connection attaches to the browser\'s existing context, which every session would share (same tabs, cookies and storage). Add --isolated to give each session its own browser context.';
|
|
642
|
+
}
|
|
643
|
+
// The CDP connection (and with it every route and page proxy) is shared by
|
|
644
|
+
// all live sessions of this factory, so nothing may close it while a
|
|
645
|
+
// sibling session still audits through it — neither a session's own
|
|
646
|
+
// close() nor the cleanup after another session's failed setup. The
|
|
647
|
+
// handout count is kept per browser object, not per factory: an external
|
|
648
|
+
// disconnect makes _obtainBrowser hand out a fresh browser while stale
|
|
649
|
+
// sessions still hold references to the old one, and a shared counter
|
|
650
|
+
// would let a stale release keep the new connection open forever (its last
|
|
651
|
+
// real user would only ever bring the count down to the stale remainder).
|
|
652
|
+
// The reference is claimed before context creation, so a sibling still
|
|
653
|
+
// inside _doCreateContext() counts and a concurrent failure cannot close
|
|
654
|
+
// the connection out from under it.
|
|
655
|
+
_sessionCounts = new WeakMap();
|
|
656
|
+
_releaseBrowser(browser) {
|
|
657
|
+
const remaining = Math.max(0, (this._sessionCounts.get(browser) ?? 1) - 1);
|
|
658
|
+
this._sessionCounts.set(browser, remaining);
|
|
659
|
+
return remaining === 0;
|
|
660
|
+
}
|
|
103
661
|
async createContext(clientInfo) {
|
|
104
662
|
testDebug('create browser context (cdp)');
|
|
105
|
-
|
|
106
|
-
|
|
663
|
+
// `obtainedPromise` guards the eager `_browserPromise` evictions below —
|
|
664
|
+
// same pattern as the base class: after an external disconnect a NEW
|
|
665
|
+
// promise may be in place, and clearing it would orphan the fresh
|
|
666
|
+
// connection other sessions are about to use. The session count is
|
|
667
|
+
// claimed atomically with the browser's delivery (see _acquireBrowser),
|
|
668
|
+
// so a sibling's close inside createContext's own await window defers to
|
|
669
|
+
// this acquisition instead of disconnecting under it.
|
|
670
|
+
const { browser, obtainedPromise } = await this._acquireBrowser(clientInfo, acquired => {
|
|
671
|
+
this._sessionCounts.set(acquired, (this._sessionCounts.get(acquired) ?? 0) + 1);
|
|
672
|
+
});
|
|
673
|
+
let browserContext;
|
|
674
|
+
try {
|
|
675
|
+
browserContext = await this._doCreateContext(browser);
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
// Without this the CDP connection stays open after e.g. an unreadable
|
|
679
|
+
// storage-state file, even though no context was ever handed out — but
|
|
680
|
+
// only when no sibling session is still using the shared connection.
|
|
681
|
+
if (this._releaseBrowser(browser) && !this._hasPendingAcquisition(obtainedPromise)) {
|
|
682
|
+
if (this._browserPromise === obtainedPromise)
|
|
683
|
+
this._browserPromise = undefined;
|
|
684
|
+
await browser.close().catch(logUnhandledError);
|
|
685
|
+
}
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
let released = false;
|
|
107
689
|
return {
|
|
108
690
|
browserContext,
|
|
109
691
|
close: async () => {
|
|
110
|
-
|
|
111
|
-
|
|
692
|
+
if (released)
|
|
693
|
+
return;
|
|
694
|
+
released = true;
|
|
695
|
+
// An isolated session's context belongs to it alone — close it now,
|
|
696
|
+
// or abandoned contexts (with their pages, routes and listeners)
|
|
697
|
+
// pile up on a long-lived shared connection until the last session
|
|
698
|
+
// exits. The non-isolated context is the browser's own and shared;
|
|
699
|
+
// it stays.
|
|
700
|
+
if (this.config.browser.isolated)
|
|
701
|
+
await browserContext.close().catch(logUnhandledError);
|
|
702
|
+
if (this._releaseBrowser(browser) && !this._hasPendingAcquisition(obtainedPromise)) {
|
|
703
|
+
// Evicted before the await so a createContext() arriving while
|
|
704
|
+
// this disconnect is still in flight obtains a fresh connection
|
|
705
|
+
// instead of the closing one — the 'disconnected' event that also
|
|
706
|
+
// clears the cache fires too late to catch that window.
|
|
707
|
+
if (this._browserPromise === obtainedPromise)
|
|
708
|
+
this._browserPromise = undefined;
|
|
709
|
+
testDebug('disconnect browser (cdp)');
|
|
710
|
+
await browser.close().catch(logUnhandledError);
|
|
711
|
+
}
|
|
112
712
|
}
|
|
113
713
|
};
|
|
114
714
|
}
|
|
@@ -120,10 +720,46 @@ class CdpContextFactory extends BaseContextFactory {
|
|
|
120
720
|
});
|
|
121
721
|
}
|
|
122
722
|
async _doCreateContext(browser) {
|
|
123
|
-
|
|
723
|
+
if (this.config.browser.isolated)
|
|
724
|
+
return await browser.newContext(this.config.browser.contextOptions);
|
|
725
|
+
const existing = browser.contexts()[0];
|
|
726
|
+
// An attached browser can expose no context at all; a fresh one created
|
|
727
|
+
// with the configured options (storage state included) beats handing an
|
|
728
|
+
// undefined context to the caller. The created context immediately seeds
|
|
729
|
+
// the applied-state memo — a later session will find it as the browser's
|
|
730
|
+
// existing context, and must join it rather than reset it — and the
|
|
731
|
+
// creation itself is memoized so concurrent arrivals share one context.
|
|
732
|
+
if (!existing) {
|
|
733
|
+
let creating = this._fallbackContext.get(browser);
|
|
734
|
+
if (!creating) {
|
|
735
|
+
creating = browser.newContext(this.config.browser.contextOptions).then(created => {
|
|
736
|
+
this._storageStateApplied.set(created, Promise.resolve());
|
|
737
|
+
// Evict on close, or a context closed externally (while the
|
|
738
|
+
// connection lives on) would keep being handed out of this memo to
|
|
739
|
+
// every later session — contexts() no longer lists it, so only the
|
|
740
|
+
// memo would remember it, forever.
|
|
741
|
+
created.on('close', () => this._fallbackContext.delete(browser));
|
|
742
|
+
return created;
|
|
743
|
+
});
|
|
744
|
+
this._fallbackContext.set(browser, creating);
|
|
745
|
+
creating.catch(() => this._fallbackContext.delete(browser));
|
|
746
|
+
}
|
|
747
|
+
return await creating;
|
|
748
|
+
}
|
|
749
|
+
// The shared promise also serializes two sessions arriving at once: both
|
|
750
|
+
// await the same application instead of racing two global resets.
|
|
751
|
+
let applied = this._storageStateApplied.get(existing);
|
|
752
|
+
if (!applied) {
|
|
753
|
+
applied = applyStorageStateToReusedContext(this.config, existing);
|
|
754
|
+
this._storageStateApplied.set(existing, applied);
|
|
755
|
+
applied.catch(() => this._storageStateApplied.delete(existing));
|
|
756
|
+
}
|
|
757
|
+
await applied;
|
|
758
|
+
return existing;
|
|
124
759
|
}
|
|
125
760
|
}
|
|
126
761
|
class RemoteContextFactory extends BaseContextFactory {
|
|
762
|
+
appliesStorageState = true;
|
|
127
763
|
constructor(config) {
|
|
128
764
|
super('remote', config);
|
|
129
765
|
}
|
|
@@ -135,40 +771,191 @@ class RemoteContextFactory extends BaseContextFactory {
|
|
|
135
771
|
return playwright[this.config.browser.browserName].connect(String(url));
|
|
136
772
|
}
|
|
137
773
|
async _doCreateContext(browser) {
|
|
138
|
-
return browser.newContext();
|
|
774
|
+
return browser.newContext(this.config.browser.contextOptions);
|
|
139
775
|
}
|
|
140
776
|
}
|
|
141
777
|
class CdpLaunchContextFactory {
|
|
142
778
|
config;
|
|
779
|
+
// See CdpContextFactory: fresh context when isolated, setStorageState otherwise.
|
|
780
|
+
appliesStorageState = true;
|
|
781
|
+
// The live child launched on a pinned --cdp-launch-port, tracked from spawn
|
|
782
|
+
// until the process exits. The sessionsUnsupportedReason veto below keeps
|
|
783
|
+
// registry sessions off this path, but DEFAULT contexts reach it too —
|
|
784
|
+
// parallel handshake-free HTTP requests each build a per-request backend
|
|
785
|
+
// whose default context launches here — and a second launch against the
|
|
786
|
+
// pinned port cannot work while the first child lives: its own child can
|
|
787
|
+
// never bind the busy port, so the connect loop attaches to the FIRST
|
|
788
|
+
// child's endpoint, the "separate" context lands in a sibling's
|
|
789
|
+
// application, and cleanup kills a child that owns nothing. Serializing
|
|
790
|
+
// the launches would not fix that — the port stays bound for the first
|
|
791
|
+
// context's whole lifetime, so a queued second launch could only time out
|
|
792
|
+
// or cross-attach after all — hence the second concurrent context is
|
|
793
|
+
// honestly rejected instead. `closing` marks a teardown in progress (kill
|
|
794
|
+
// sent), which a new arrival may briefly wait out rather than failing a
|
|
795
|
+
// plain sequential close-then-relaunch on the OS shutdown tail.
|
|
796
|
+
_pinnedPortLaunch;
|
|
143
797
|
constructor(config) {
|
|
144
798
|
this.config = config;
|
|
145
799
|
}
|
|
800
|
+
// Without --isolated a session would reuse a launched application's single
|
|
801
|
+
// existing context. With --isolated each context is created fresh, at the
|
|
802
|
+
// documented cost of launching another application instance per context —
|
|
803
|
+
// unless the port is pinned: then every session's child is launched against
|
|
804
|
+
// the SAME endpoint, the second session's connect loop reaches the first
|
|
805
|
+
// session's instance (its own child never bound the busy port), its
|
|
806
|
+
// "separate" context lands in a sibling's application, and its cleanup
|
|
807
|
+
// kills a child that owns nothing while leaking that context.
|
|
808
|
+
get sessionsUnsupportedReason() {
|
|
809
|
+
if (!this.config.browser.isolated)
|
|
810
|
+
return 'without --isolated each session would attach to a launched application\'s single shared context (same tabs, cookies and storage). Add --isolated to give each session its own browser context.';
|
|
811
|
+
if (this.config.browser.cdpLaunch?.port !== undefined)
|
|
812
|
+
return 'the pinned --cdp-launch-port can serve only one launched application at a time, so a second session would attach to the first session\'s instance. Drop --cdp-launch-port (each session then launches on its own free port) or run one session at a time.';
|
|
813
|
+
return undefined;
|
|
814
|
+
}
|
|
146
815
|
async createContext(clientInfo) {
|
|
147
816
|
const cdpLaunch = this.config.browser.cdpLaunch;
|
|
148
|
-
|
|
817
|
+
if (cdpLaunch.port !== undefined) {
|
|
818
|
+
await this._waitForClosingPinnedPortHolder(cdpLaunch);
|
|
819
|
+
// Checked synchronously with the spawn-and-track below (nothing awaits
|
|
820
|
+
// in between on the pinned path): a concurrent createContext() resuming
|
|
821
|
+
// from the same wait could otherwise interleave here and both would
|
|
822
|
+
// launch against the one port.
|
|
823
|
+
this._assertPinnedPortFree(cdpLaunch);
|
|
824
|
+
}
|
|
825
|
+
// Reserved until the child is known to have bound the port (or the launch
|
|
826
|
+
// failed): findFreePort()'s probe socket is closed before the child
|
|
827
|
+
// spawns, so a concurrent session's probe could otherwise be handed the
|
|
828
|
+
// same port — both connect loops would then attach to whichever child
|
|
829
|
+
// bound first, sharing its context and killing the wrong child on
|
|
830
|
+
// cleanup (exactly the confusion the pinned-port session veto exists to
|
|
831
|
+
// prevent). In-process reservation suffices: the realistic collision
|
|
832
|
+
// source is concurrent createContext() calls in this process racing one
|
|
833
|
+
// OS port pool.
|
|
834
|
+
const allocatedPort = cdpLaunch.port === undefined ? await findFreePort({ reserve: true }) : undefined;
|
|
835
|
+
const port = cdpLaunch.port ?? allocatedPort;
|
|
149
836
|
const endpoint = `http://127.0.0.1:${port}`;
|
|
150
837
|
const args = (cdpLaunch.args ?? []).map(arg => arg.replaceAll('{port}', String(port)));
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
838
|
+
let browser;
|
|
839
|
+
let childProcess;
|
|
840
|
+
let pinnedLaunch;
|
|
841
|
+
try {
|
|
842
|
+
childProcess = spawn(cdpLaunch.command, args, {
|
|
843
|
+
cwd: cdpLaunch.cwd,
|
|
844
|
+
env: {
|
|
845
|
+
...process.env,
|
|
846
|
+
...cdpLaunch.env,
|
|
847
|
+
},
|
|
848
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
849
|
+
});
|
|
850
|
+
if (cdpLaunch.port !== undefined)
|
|
851
|
+
pinnedLaunch = this._trackPinnedPortChild(childProcess);
|
|
852
|
+
childProcess.stderr?.on('data', data => {
|
|
853
|
+
testDebug(`cdp-launch stderr: ${String(data).trimEnd()}`);
|
|
854
|
+
});
|
|
855
|
+
// A successful connect proves the child owns the port, so the OS can no
|
|
856
|
+
// longer hand it to a sibling's probe; on failure the child is already
|
|
857
|
+
// killed and the port free again.
|
|
858
|
+
browser = await this._waitForBrowser(endpoint, clientInfo, childProcess, cdpLaunch.startupTimeoutMs ?? 30000);
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
// _waitForBrowser has already killed the child on failure; marking the
|
|
862
|
+
// tracked launch closing lets the next pinned-port context wait out the
|
|
863
|
+
// exit instead of rejecting against a corpse.
|
|
864
|
+
if (pinnedLaunch)
|
|
865
|
+
pinnedLaunch.closing = true;
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
finally {
|
|
869
|
+
if (allocatedPort !== undefined)
|
|
870
|
+
reservedPorts.delete(allocatedPort);
|
|
871
|
+
}
|
|
872
|
+
let browserContext;
|
|
873
|
+
try {
|
|
874
|
+
if (this.config.browser.isolated) {
|
|
875
|
+
browserContext = await browser.newContext(this.config.browser.contextOptions);
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
const existing = browser.contexts()[0];
|
|
879
|
+
if (existing) {
|
|
880
|
+
await applyStorageStateToReusedContext(this.config, existing);
|
|
881
|
+
browserContext = existing;
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
// See CdpContextFactory: a launched app can expose no context yet;
|
|
885
|
+
// a fresh one with the configured options beats an undefined one.
|
|
886
|
+
browserContext = await browser.newContext(this.config.browser.contextOptions);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
// The desktop process is already running by now; failing to obtain a
|
|
892
|
+
// context (say, an unreadable storage-state file) must not leave it and
|
|
893
|
+
// the CDP connection behind with nobody holding a close() for them.
|
|
894
|
+
if (pinnedLaunch)
|
|
895
|
+
pinnedLaunch.closing = true;
|
|
896
|
+
await browser.close().catch(logUnhandledError);
|
|
897
|
+
childProcess.kill('SIGTERM');
|
|
898
|
+
throw error;
|
|
899
|
+
}
|
|
164
900
|
return {
|
|
165
901
|
browserContext,
|
|
166
902
|
close: async () => {
|
|
903
|
+
if (pinnedLaunch)
|
|
904
|
+
pinnedLaunch.closing = true;
|
|
167
905
|
await browser.close().catch(logUnhandledError);
|
|
168
906
|
childProcess.kill('SIGTERM');
|
|
169
907
|
}
|
|
170
908
|
};
|
|
171
909
|
}
|
|
910
|
+
/**
|
|
911
|
+
* Waits out a pinned-port holder whose teardown has already begun (kill
|
|
912
|
+
* sent, exit pending), bounded by the configured startup timeout — the
|
|
913
|
+
* same budget a launch gets — so a plain sequential close-then-relaunch
|
|
914
|
+
* does not flake on the child's asynchronous exit. A holder that is NOT
|
|
915
|
+
* closing is genuine concurrency; _assertPinnedPortFree rejects it.
|
|
916
|
+
*/
|
|
917
|
+
async _waitForClosingPinnedPortHolder(cdpLaunch) {
|
|
918
|
+
const previous = this._pinnedPortLaunch;
|
|
919
|
+
if (!previous?.closing)
|
|
920
|
+
return;
|
|
921
|
+
let timer;
|
|
922
|
+
try {
|
|
923
|
+
await Promise.race([
|
|
924
|
+
previous.exited,
|
|
925
|
+
new Promise(resolve => {
|
|
926
|
+
timer = setTimeout(resolve, cdpLaunch.startupTimeoutMs ?? 30000);
|
|
927
|
+
timer.unref?.();
|
|
928
|
+
}),
|
|
929
|
+
]);
|
|
930
|
+
}
|
|
931
|
+
finally {
|
|
932
|
+
clearTimeout(timer);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/** Rejects a pinned-port launch while another live context's child still
|
|
936
|
+
* holds the port (see _pinnedPortLaunch). Synchronous, so callers can bind
|
|
937
|
+
* the check to the spawn without an interleaving window. */
|
|
938
|
+
_assertPinnedPortFree(cdpLaunch) {
|
|
939
|
+
if (this._pinnedPortLaunch)
|
|
940
|
+
throw new Error(`The pinned --cdp-launch-port ${cdpLaunch.port} already serves a launched application from another live browser context, and a second launch on the same port would silently attach to that application instead of its own. Close the other context first, or drop --cdp-launch-port so each context launches on its own free port.`);
|
|
941
|
+
}
|
|
942
|
+
/** Tracks the pinned-port child until its process is gone; identity-guarded
|
|
943
|
+
* so a stale exit can never untrack a successor's launch. The 'error'
|
|
944
|
+
* listener covers a spawn that never produces an 'exit' (e.g. ENOENT). */
|
|
945
|
+
_trackPinnedPortChild(childProcess) {
|
|
946
|
+
const launch = { closing: false, exited: undefined };
|
|
947
|
+
launch.exited = new Promise(resolve => {
|
|
948
|
+
const done = () => {
|
|
949
|
+
if (this._pinnedPortLaunch === launch)
|
|
950
|
+
this._pinnedPortLaunch = undefined;
|
|
951
|
+
resolve();
|
|
952
|
+
};
|
|
953
|
+
childProcess.once('exit', done);
|
|
954
|
+
childProcess.once('error', done);
|
|
955
|
+
});
|
|
956
|
+
this._pinnedPortLaunch = launch;
|
|
957
|
+
return launch;
|
|
958
|
+
}
|
|
172
959
|
async _waitForBrowser(endpoint, clientInfo, childProcess, startupTimeoutMs) {
|
|
173
960
|
const deadline = Date.now() + startupTimeoutMs;
|
|
174
961
|
const connectOptions = {
|
|
@@ -191,67 +978,301 @@ class CdpLaunchContextFactory {
|
|
|
191
978
|
}
|
|
192
979
|
}
|
|
193
980
|
}
|
|
194
|
-
|
|
981
|
+
// Distinguishes a storage-state failure from a launch failure, so the launch
|
|
982
|
+
// retry loop never retries on one (a bad state file fails the same way 5 times).
|
|
983
|
+
class StorageStateError extends Error {
|
|
984
|
+
}
|
|
985
|
+
// Shared with the --connect-tool startup validation in program.ts, so the
|
|
986
|
+
// lazy rejection here and the eager one there never drift apart.
|
|
987
|
+
export const persistentProfileConflictRemedy = 'Drop --user-data-dir (a managed, disposable profile is used for storage-state sessions), or drop the storage state and sign in in that profile instead.';
|
|
988
|
+
export class PersistentContextFactory {
|
|
195
989
|
config;
|
|
990
|
+
// launchPersistentContext() silently ignores a storageState option, so the
|
|
991
|
+
// state is applied to the launched context with setStorageState() instead.
|
|
992
|
+
appliesStorageState = true;
|
|
196
993
|
name = 'persistent';
|
|
197
994
|
description = 'Create a new persistent browser context';
|
|
198
995
|
_userDataDirs = new Set();
|
|
996
|
+
// Set while a live context (or one still launching) holds the stable
|
|
997
|
+
// `mcp-<browser>-<workspace>` profile. The profile can back only one running browser at
|
|
998
|
+
// a time (Chromium's ProcessSingleton lock), and every stateful backend's
|
|
999
|
+
// default context resolves to it — one such context under stdio, but each
|
|
1000
|
+
// concurrent Mcp-Session-Id HTTP client brings its own backend, and the
|
|
1001
|
+
// second used to spin on the lock and fail with "Browser is already in
|
|
1002
|
+
// use". The stable profile goes to the FIRST claimant; genuinely
|
|
1003
|
+
// concurrent claimants fall back to a disposable profile (their audit
|
|
1004
|
+
// runs, without the stable profile's sign-in state), and the claim is
|
|
1005
|
+
// released when the holder's context closes so the next default context —
|
|
1006
|
+
// and the profile's persisted state — line up again. Checked-and-set
|
|
1007
|
+
// synchronously, so concurrent createContext() calls cannot both claim.
|
|
1008
|
+
//
|
|
1009
|
+
// A holder that has BEGUN closing (`closing`, set via the handle's
|
|
1010
|
+
// closeStarting notice) is a release in progress, not genuine concurrency:
|
|
1011
|
+
// its async shutdown — dominated by the Context's bounded pending-download
|
|
1012
|
+
// drain — can outlast a --connect-tool/--vscode provider switch-away, and a
|
|
1013
|
+
// claimant arriving in that window (the user switching back) used to be
|
|
1014
|
+
// silently demoted to a disposable profile, losing the stable profile's
|
|
1015
|
+
// sign-in state. Such a claimant now waits on `released` (bounded: the
|
|
1016
|
+
// drain is capped at 30s and the browser shutdown bounds the rest) and
|
|
1017
|
+
// then claims the stable profile itself.
|
|
1018
|
+
_stableProfileClaim;
|
|
1019
|
+
// Claims the stable profile for one context. Synchronous, so a concurrent
|
|
1020
|
+
// createContext() cannot interleave between check and set.
|
|
1021
|
+
_claimStableProfile() {
|
|
1022
|
+
let resolveReleased;
|
|
1023
|
+
const released = new Promise(resolve => { resolveReleased = resolve; });
|
|
1024
|
+
const claim = {
|
|
1025
|
+
closing: false,
|
|
1026
|
+
released,
|
|
1027
|
+
release: () => {
|
|
1028
|
+
if (this._stableProfileClaim === claim)
|
|
1029
|
+
this._stableProfileClaim = undefined;
|
|
1030
|
+
resolveReleased();
|
|
1031
|
+
},
|
|
1032
|
+
};
|
|
1033
|
+
this._stableProfileClaim = claim;
|
|
1034
|
+
return claim;
|
|
1035
|
+
}
|
|
199
1036
|
constructor(config) {
|
|
200
1037
|
this.config = config;
|
|
201
1038
|
}
|
|
202
|
-
|
|
203
|
-
|
|
1039
|
+
// A user-supplied profile directory can back only one running browser at a
|
|
1040
|
+
// time (Chromium's ProcessSingleton lock), and minting disposable profiles
|
|
1041
|
+
// behind the user's back would silently drop the sign-in state they asked
|
|
1042
|
+
// for — so explicit sessions are refused in that configuration. Without
|
|
1043
|
+
// --user-data-dir, sessions run in their own disposable profiles below.
|
|
1044
|
+
get sessionsUnsupportedReason() {
|
|
1045
|
+
if (this.config.browser.userDataDir)
|
|
1046
|
+
return 'the configured --user-data-dir profile can back only one running browser at a time. Drop --user-data-dir (extra sessions run in their own disposable profiles) or use --isolated.';
|
|
1047
|
+
return undefined;
|
|
1048
|
+
}
|
|
1049
|
+
async createContext(clientInfo, _abortSignal, _toolName, options) {
|
|
204
1050
|
testDebug('create browser context (persistent)');
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
1051
|
+
// launchPersistentContext() accepts a storageState option without applying
|
|
1052
|
+
// it (verified against 1.61.1) — the profile is normally the state — so it
|
|
1053
|
+
// is stripped here and applied explicitly after launch.
|
|
1054
|
+
const { storageState, ...contextOptions } = this.config.browser.contextOptions ?? {};
|
|
1055
|
+
// setStorageState() resets cookies globally but origin storage only for
|
|
1056
|
+
// origins in the state or known to the fresh context object, so stale
|
|
1057
|
+
// localStorage/IndexedDB in a previously used profile would survive and
|
|
1058
|
+
// could sign the audit in as the wrong identity. A storage-state session
|
|
1059
|
+
// therefore runs in its own fresh disposable profile — unique per context,
|
|
1060
|
+
// because one server can hold several live sessions and a shared
|
|
1061
|
+
// deterministic directory would let one session's setup destroy another's
|
|
1062
|
+
// running profile — removed again when the context closes. A user-supplied
|
|
1063
|
+
// profile cannot be treated this way — it is data we do not own — and
|
|
1064
|
+
// keeping it contradicts "start from the recorded state", so that
|
|
1065
|
+
// combination errors.
|
|
1066
|
+
assertStorageStateDoesNotResetUserProfile(this.config, persistentProfileConflictRemedy);
|
|
1067
|
+
// Trace setup runs before the disposable profile exists: it can fail (an
|
|
1068
|
+
// unwritable output directory), and nothing after the directory is created
|
|
1069
|
+
// may throw outside the cleanup scope below, or failed starts would leave
|
|
1070
|
+
// stray profiles behind.
|
|
1071
|
+
const tracesDir = await startTraceServer(this.config);
|
|
1072
|
+
// Per-launch and reserved until this launch binds it: two explicit
|
|
1073
|
+
// sessions launching concurrently interleave their awaits here, and a
|
|
1074
|
+
// port written into the shared config would be overwritten by the
|
|
1075
|
+
// sibling before launchPersistentContext() reads it — both browsers
|
|
1076
|
+
// then race for one port and one fails to bind.
|
|
1077
|
+
const { cdpPortOptions, releaseCdpPort } = await allocateCdpPort(this.config.browser);
|
|
1078
|
+
// An explicitly opened browser session gets its own disposable profile for
|
|
1079
|
+
// the same reason a storage-state context does: the stable profile can back
|
|
1080
|
+
// only one running browser, so a second session sharing it would spin on
|
|
1081
|
+
// the ProcessSingleton lock and fail with "Browser is already in use". The
|
|
1082
|
+
// DEFAULT (no-handle) context keeps the stable `mcp-<browser>-<workspace>` profile, so
|
|
1083
|
+
// its sign-in state still survives restarts.
|
|
1084
|
+
let profileSuffix = storageState
|
|
1085
|
+
? `-storage-state-${createGuid()}`
|
|
1086
|
+
: options?.browserSession
|
|
1087
|
+
? `-session-${createGuid()}`
|
|
1088
|
+
: '';
|
|
1089
|
+
let claim;
|
|
1090
|
+
let userDataDir;
|
|
1091
|
+
let disposableProfile = false;
|
|
209
1092
|
const browserType = playwright[this.config.browser.browserName];
|
|
210
|
-
|
|
1093
|
+
// The cleanup scope opens right after the port reservation: the
|
|
1094
|
+
// profile-directory mkdir below can reject too (e.g. a transient volume
|
|
1095
|
+
// failure), and a failure between the claim and the launch loop used to
|
|
1096
|
+
// escape the cleanup — the claim was never reset (every later default
|
|
1097
|
+
// context misclassified as concurrent and demoted to a disposable
|
|
1098
|
+
// profile) and the reserved CDP port was never released.
|
|
1099
|
+
try {
|
|
1100
|
+
// A default (no-suffix) context claims the stable profile — unless a
|
|
1101
|
+
// sibling already holds it (see _stableProfileClaim): then it runs in
|
|
1102
|
+
// a disposable profile instead of failing the launch. A user-supplied
|
|
1103
|
+
// --user-data-dir is exempt: silently substituting a disposable profile
|
|
1104
|
+
// would drop the sign-in state the user explicitly asked for, so that
|
|
1105
|
+
// configuration keeps the launch-time contention error.
|
|
1106
|
+
if (!profileSuffix && !this.config.browser.userDataDir) {
|
|
1107
|
+
// A holder that has begun closing is a release in progress, not
|
|
1108
|
+
// genuine concurrency: wait for the release (bounded by the holder's
|
|
1109
|
+
// capped download drain and browser shutdown) instead of silently
|
|
1110
|
+
// demoting this context to a disposable profile. Re-checked after
|
|
1111
|
+
// the wait — another claimant may have won the freed claim.
|
|
1112
|
+
const holder = this._stableProfileClaim;
|
|
1113
|
+
if (holder?.closing) {
|
|
1114
|
+
testDebug('stable persistent profile holder is closing; waiting for its release');
|
|
1115
|
+
await holder.released;
|
|
1116
|
+
}
|
|
1117
|
+
if (this._stableProfileClaim) {
|
|
1118
|
+
profileSuffix = `-concurrent-${createGuid()}`;
|
|
1119
|
+
testDebug('stable persistent profile is in use by a concurrent context; falling back to a disposable profile');
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
claim = this._claimStableProfile();
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(profileSuffix);
|
|
1126
|
+
// Guarded on the config profile too: sessionsUnsupportedReason keeps
|
|
1127
|
+
// registry sessions out of a user-supplied --user-data-dir, so a suffix
|
|
1128
|
+
// here always means the guid-fresh managed directory above — but a direct
|
|
1129
|
+
// caller combining both must still never see the user's profile deleted.
|
|
1130
|
+
disposableProfile = !!profileSuffix && !this.config.browser.userDataDir;
|
|
1131
|
+
this._userDataDirs.add(userDataDir);
|
|
1132
|
+
testDebug('lock user data dir', userDataDir);
|
|
1133
|
+
for (let i = 0; i < 5; i++) {
|
|
1134
|
+
try {
|
|
1135
|
+
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
|
1136
|
+
tracesDir,
|
|
1137
|
+
...this.config.browser.launchOptions,
|
|
1138
|
+
...cdpPortOptions,
|
|
1139
|
+
...contextOptions,
|
|
1140
|
+
handleSIGINT: false,
|
|
1141
|
+
handleSIGTERM: false,
|
|
1142
|
+
});
|
|
1143
|
+
const result = await this._applyStorageState(browserContext, storageState, userDataDir, disposableProfile);
|
|
1144
|
+
if (!claim)
|
|
1145
|
+
return result;
|
|
1146
|
+
const heldClaim = claim;
|
|
1147
|
+
return {
|
|
1148
|
+
browserContext: result.browserContext,
|
|
1149
|
+
// The owning Context's advance notice that close() will follow
|
|
1150
|
+
// once its async cleanup (the bounded download drain) finishes:
|
|
1151
|
+
// from here on a new default claimant waits for the release
|
|
1152
|
+
// instead of treating this holder as genuine concurrency.
|
|
1153
|
+
closeStarting: () => { heldClaim.closing = true; },
|
|
1154
|
+
close: async () => {
|
|
1155
|
+
try {
|
|
1156
|
+
await result.close();
|
|
1157
|
+
}
|
|
1158
|
+
finally {
|
|
1159
|
+
// Released only after the browser has shut down, so the next
|
|
1160
|
+
// claimant's launch meets a freed ProcessSingleton lock (the
|
|
1161
|
+
// launch retry loop covers the OS-level shutdown tail).
|
|
1162
|
+
// release() is idempotent and identity-guarded, so a repeated
|
|
1163
|
+
// close() can never free a claim a successor context holds.
|
|
1164
|
+
heldClaim.release();
|
|
1165
|
+
}
|
|
1166
|
+
},
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
if (error instanceof StorageStateError)
|
|
1171
|
+
throw error;
|
|
1172
|
+
if (error.message.includes('Executable doesn\'t exist'))
|
|
1173
|
+
throw browserNotInstalledError(error);
|
|
1174
|
+
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
|
1175
|
+
// User data directory is already in use, try again.
|
|
1176
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
throw error;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
|
1183
|
+
}
|
|
1184
|
+
catch (error) {
|
|
1185
|
+
// A claim that never produced a context must not pin the stable profile
|
|
1186
|
+
// forever — the next default context would needlessly fall back to a
|
|
1187
|
+
// disposable profile with the stable one sitting free.
|
|
1188
|
+
claim?.release();
|
|
1189
|
+
// The disposable profile belongs to this context alone, so a launch that
|
|
1190
|
+
// never produced a context must not leave it behind — repeated failed
|
|
1191
|
+
// starts would otherwise pile one stray directory into the registry each.
|
|
1192
|
+
// (Already removed on the StorageStateError path; rm is idempotent.)
|
|
1193
|
+
if (disposableProfile && userDataDir !== undefined) {
|
|
1194
|
+
await fs.promises.rm(userDataDir, { recursive: true, force: true }).catch(() => { });
|
|
1195
|
+
this._userDataDirs.delete(userDataDir);
|
|
1196
|
+
}
|
|
1197
|
+
throw error;
|
|
1198
|
+
}
|
|
1199
|
+
finally {
|
|
1200
|
+
// Bound by the launched browser on success, free for reuse on failure —
|
|
1201
|
+
// either way the reservation has served its purpose.
|
|
1202
|
+
releaseCdpPort();
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
// Separate from the launch retry loop: its `catch` retries on messages a
|
|
1206
|
+
// malformed storage-state file could coincidentally match (`Invalid URL`).
|
|
1207
|
+
async _applyStorageState(browserContext, storageState, userDataDir, disposableProfile) {
|
|
1208
|
+
if (storageState) {
|
|
211
1209
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
|
220
|
-
return { browserContext, close };
|
|
1210
|
+
// Startup pages can keep persisting their anonymous identity while the
|
|
1211
|
+
// state lands. Park them on blank replacements before the apply, then
|
|
1212
|
+
// navigate the replacements only after the recorded state is installed.
|
|
1213
|
+
const replaced = await replaceOpenPagesWithBlankTabs(browserContext);
|
|
1214
|
+
await browserContext.setStorageState(storageState);
|
|
1215
|
+
await ensureNetworkPolicyRoutes(this.config, browserContext);
|
|
1216
|
+
await navigateReplacementPages(replaced);
|
|
221
1217
|
}
|
|
222
1218
|
catch (error) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
throw error;
|
|
1219
|
+
// Nobody holds a close() for this context yet, so a bad storage-state
|
|
1220
|
+
// file must not leave the launched browser running.
|
|
1221
|
+
await this._closeBrowserContext(browserContext, userDataDir, disposableProfile);
|
|
1222
|
+
throw new StorageStateError(error instanceof Error ? error.message : String(error));
|
|
231
1223
|
}
|
|
232
1224
|
}
|
|
233
|
-
|
|
1225
|
+
const close = () => this._closeBrowserContext(browserContext, userDataDir, disposableProfile);
|
|
1226
|
+
return { browserContext, close };
|
|
234
1227
|
}
|
|
235
|
-
async _closeBrowserContext(browserContext, userDataDir) {
|
|
1228
|
+
async _closeBrowserContext(browserContext, userDataDir, disposeUserDataDir = false) {
|
|
236
1229
|
testDebug('close browser context (persistent)');
|
|
237
1230
|
testDebug('release user data dir', userDataDir);
|
|
238
1231
|
await browserContext.close().catch(() => { });
|
|
1232
|
+
// A storage-state or browser-session profile is unique to this context and
|
|
1233
|
+
// holds nothing worth keeping — the state file (or the default profile) is
|
|
1234
|
+
// the durable copy — so it is removed rather than left to pile up next to
|
|
1235
|
+
// the regular persistent profile.
|
|
1236
|
+
if (disposeUserDataDir)
|
|
1237
|
+
await fs.promises.rm(userDataDir, { recursive: true, force: true }).catch(() => { });
|
|
239
1238
|
this._userDataDirs.delete(userDataDir);
|
|
240
1239
|
testDebug('close browser context complete (persistent)');
|
|
241
1240
|
}
|
|
242
|
-
|
|
1241
|
+
// The suffix keeps disposable storage-state and browser-session profiles
|
|
1242
|
+
// apart from the regular persistent profile (and, carrying a per-context
|
|
1243
|
+
// guid, from each other), so removing one can never destroy an interactive
|
|
1244
|
+
// session or a sibling's.
|
|
1245
|
+
//
|
|
1246
|
+
// The workspace token keeps different servers' stable profiles apart. MCP
|
|
1247
|
+
// clients typically launch one stdio server per workspace, cwd'd into it, so
|
|
1248
|
+
// hashing process.cwd() gives each workspace its own deterministic profile:
|
|
1249
|
+
// sign-in state survives restarts of the same server (same cwd, same hash),
|
|
1250
|
+
// while servers for other workspaces neither contend for this profile's
|
|
1251
|
+
// ProcessSingleton lock nor inherit its cookies and storage. (This restores
|
|
1252
|
+
// the separation the deprecated MCP Roots hash used to provide — keyed on
|
|
1253
|
+
// the server's own launch directory instead of a client-reported root, so it
|
|
1254
|
+
// covers every client rather than only those that exposed roots.)
|
|
1255
|
+
async _createUserDataDir(suffix) {
|
|
243
1256
|
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
|
|
244
1257
|
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
|
|
1258
|
+
const workspaceToken = `-${createHash(process.cwd())}`;
|
|
1259
|
+
const result = path.join(dir, `mcp-${browserToken}${workspaceToken}${suffix}`);
|
|
248
1260
|
await fs.promises.mkdir(result, { recursive: true });
|
|
249
1261
|
return result;
|
|
250
1262
|
}
|
|
251
1263
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
1264
|
+
/**
|
|
1265
|
+
* Allocates the CDP port for a Chromium launch, reserved until that launch has
|
|
1266
|
+
* bound it (or failed). The port travels in per-launch options instead of
|
|
1267
|
+
* being written into the shared config: concurrent launches from one factory
|
|
1268
|
+
* (e.g. two explicit persistent sessions) would otherwise overwrite each
|
|
1269
|
+
* other's `cdpPort` between allocation and launch and race for a single port.
|
|
1270
|
+
*/
|
|
1271
|
+
async function allocateCdpPort(browserConfig) {
|
|
1272
|
+
if (browserConfig.browserName !== 'chromium')
|
|
1273
|
+
return { cdpPortOptions: {}, releaseCdpPort: () => { } };
|
|
1274
|
+
const cdpPort = await findFreePort({ reserve: true });
|
|
1275
|
+
return { cdpPortOptions: { cdpPort }, releaseCdpPort: () => reservedPorts.delete(cdpPort) };
|
|
255
1276
|
}
|
|
256
1277
|
/**
|
|
257
1278
|
* Builds the HTTP headers sent with a `connectOverCDP` request: the client
|
|
@@ -266,15 +1287,33 @@ function cdpConnectHeaders(clientInfo, browserConfig) {
|
|
|
266
1287
|
Object.assign(headers, browserConfig.cdpHeaders);
|
|
267
1288
|
return Object.keys(headers).length ? headers : undefined;
|
|
268
1289
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
1290
|
+
/**
|
|
1291
|
+
* Ports handed out by `findFreePort({ reserve: true })` whose intended owner
|
|
1292
|
+
* has not bound them yet. The probe socket below is closed before the caller
|
|
1293
|
+
* uses the port, so without this set two concurrent allocations in this
|
|
1294
|
+
* process could be handed the same port. Module-level because every factory
|
|
1295
|
+
* in the process draws from the one OS port pool.
|
|
1296
|
+
*/
|
|
1297
|
+
const reservedPorts = new Set();
|
|
1298
|
+
async function findFreePort(options) {
|
|
1299
|
+
for (;;) {
|
|
1300
|
+
const port = await new Promise((resolve, reject) => {
|
|
1301
|
+
const server = net.createServer();
|
|
1302
|
+
server.listen(0, () => {
|
|
1303
|
+
const { port } = server.address();
|
|
1304
|
+
server.close(() => resolve(port));
|
|
1305
|
+
});
|
|
1306
|
+
server.on('error', reject);
|
|
275
1307
|
});
|
|
276
|
-
|
|
277
|
-
|
|
1308
|
+
// Reserved ports are skipped for every caller — nothing may be pointed at
|
|
1309
|
+
// a port a launched child is still starting up on. The check-and-reserve
|
|
1310
|
+
// is synchronous, so concurrent allocations cannot interleave inside it.
|
|
1311
|
+
if (reservedPorts.has(port))
|
|
1312
|
+
continue;
|
|
1313
|
+
if (options?.reserve)
|
|
1314
|
+
reservedPorts.add(port);
|
|
1315
|
+
return port;
|
|
1316
|
+
}
|
|
278
1317
|
}
|
|
279
1318
|
/**
|
|
280
1319
|
* Builds the user-facing "browser not installed" error from Playwright's raw
|
|
@@ -289,10 +1328,38 @@ function browserNotInstalledError(error) {
|
|
|
289
1328
|
const location = match ? `; expected executable at ${match[1].trim()}` : '';
|
|
290
1329
|
return new Error(`Browser specified in your config is not installed${location}. Either install it (likely) or change the config.`);
|
|
291
1330
|
}
|
|
292
|
-
|
|
1331
|
+
// One trace-viewer server and traces directory per config — i.e. per server
|
|
1332
|
+
// run, the same WeakMap pattern as resolveOutputDir. startTraceViewerServer()
|
|
1333
|
+
// binds a listening HTTP socket that nothing ever closes, so starting one per
|
|
1334
|
+
// browser launch (the persistent factory launches per context, so every
|
|
1335
|
+
// explicit-session open/close cycle) leaked a listener for the life of the
|
|
1336
|
+
// process. Sharing one tracesDir across launches is safe for the trace files:
|
|
1337
|
+
// each Context records under its own `trace-<guid>` name (see acquireTrace in
|
|
1338
|
+
// context.ts), exactly as --isolated mode has always shared its per-browser
|
|
1339
|
+
// tracesDir.
|
|
1340
|
+
const traceServers = new WeakMap();
|
|
1341
|
+
async function startTraceServer(config) {
|
|
293
1342
|
if (!config.saveTrace)
|
|
294
1343
|
return undefined;
|
|
295
|
-
|
|
1344
|
+
let started = traceServers.get(config);
|
|
1345
|
+
if (!started) {
|
|
1346
|
+
started = doStartTraceServer(config);
|
|
1347
|
+
traceServers.set(config, started);
|
|
1348
|
+
// A failed start (e.g. an unwritable output directory) is not memoized:
|
|
1349
|
+
// the next launch retries instead of replaying the rejection for the
|
|
1350
|
+
// process lifetime. Guarded by identity — a retry may already have
|
|
1351
|
+
// stored a fresh in-flight promise by the time this handler runs.
|
|
1352
|
+
started.catch(() => {
|
|
1353
|
+
if (traceServers.get(config) === started)
|
|
1354
|
+
traceServers.delete(config);
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
return started;
|
|
1358
|
+
}
|
|
1359
|
+
async function doStartTraceServer(config) {
|
|
1360
|
+
// The random suffix keeps two configs resolving in the same millisecond
|
|
1361
|
+
// from sharing a trace folder. Nothing parses the folder name back.
|
|
1362
|
+
const tracesDir = await outputFile(config, `traces-${Date.now()}-${createShortGuid()}`);
|
|
296
1363
|
const server = await startTraceViewerServer();
|
|
297
1364
|
const urlPrefix = server.urlPrefix('human-readable');
|
|
298
1365
|
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
|