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.
Files changed (85) hide show
  1. package/README.md +265 -12
  2. package/index.d.ts +1 -1
  3. package/lib/browserContextFactory.js +1161 -94
  4. package/lib/browserContextFactory.js.map +1 -1
  5. package/lib/browserServerBackend.js +199 -22
  6. package/lib/browserServerBackend.js.map +1 -1
  7. package/lib/browserSessions.js +172 -0
  8. package/lib/browserSessions.js.map +1 -0
  9. package/lib/config.js +48 -7
  10. package/lib/config.js.map +1 -1
  11. package/lib/context.js +321 -71
  12. package/lib/context.js.map +1 -1
  13. package/lib/extension/cdpRelay.js +5 -0
  14. package/lib/extension/cdpRelay.js.map +1 -1
  15. package/lib/extension/extensionContextFactory.js +12 -1
  16. package/lib/extension/extensionContextFactory.js.map +1 -1
  17. package/lib/index.js +5 -1
  18. package/lib/index.js.map +1 -1
  19. package/lib/mcp/http.js +194 -44
  20. package/lib/mcp/http.js.map +1 -1
  21. package/lib/mcp/inProcessTransport.js.map +1 -1
  22. package/lib/mcp/mdb.js +7 -9
  23. package/lib/mcp/mdb.js.map +1 -1
  24. package/lib/mcp/proxyBackend.js +76 -18
  25. package/lib/mcp/proxyBackend.js.map +1 -1
  26. package/lib/mcp/server.js +70 -41
  27. package/lib/mcp/server.js.map +1 -1
  28. package/lib/mcp/sharedClientSlot.js +134 -0
  29. package/lib/mcp/sharedClientSlot.js.map +1 -0
  30. package/lib/mcp/tool.js +3 -0
  31. package/lib/mcp/tool.js.map +1 -1
  32. package/lib/networkPolicy.js +73 -0
  33. package/lib/networkPolicy.js.map +1 -0
  34. package/lib/program.js +91 -12
  35. package/lib/program.js.map +1 -1
  36. package/lib/response.js +16 -3
  37. package/lib/response.js.map +1 -1
  38. package/lib/sessionLog.js +36 -6
  39. package/lib/sessionLog.js.map +1 -1
  40. package/lib/tab.js +149 -18
  41. package/lib/tab.js.map +1 -1
  42. package/lib/tools/auditKeyboard.js +204 -4
  43. package/lib/tools/auditKeyboard.js.map +1 -1
  44. package/lib/tools/auditScreenReader.js +823 -0
  45. package/lib/tools/auditScreenReader.js.map +1 -0
  46. package/lib/tools/auditSite.js +268 -90
  47. package/lib/tools/auditSite.js.map +1 -1
  48. package/lib/tools/axe.js +479 -21
  49. package/lib/tools/axe.js.map +1 -1
  50. package/lib/tools/dialogs.js +18 -4
  51. package/lib/tools/dialogs.js.map +1 -1
  52. package/lib/tools/evaluate.js +34 -5
  53. package/lib/tools/evaluate.js.map +1 -1
  54. package/lib/tools/network.js +136 -8
  55. package/lib/tools/network.js.map +1 -1
  56. package/lib/tools/pdf.js +5 -2
  57. package/lib/tools/pdf.js.map +1 -1
  58. package/lib/tools/scanPageMatrix.js +135 -47
  59. package/lib/tools/scanPageMatrix.js.map +1 -1
  60. package/lib/tools/screenshot.js +8 -4
  61. package/lib/tools/screenshot.js.map +1 -1
  62. package/lib/tools/session.js +53 -0
  63. package/lib/tools/session.js.map +1 -0
  64. package/lib/tools/snapshot.js +266 -8
  65. package/lib/tools/snapshot.js.map +1 -1
  66. package/lib/tools/tool.js.map +1 -1
  67. package/lib/tools/utils.js +62 -6
  68. package/lib/tools/utils.js.map +1 -1
  69. package/lib/tools.js +11 -2
  70. package/lib/tools.js.map +1 -1
  71. package/lib/utils/dataUrl.js +58 -35
  72. package/lib/utils/dataUrl.js.map +1 -1
  73. package/lib/utils/fileUtils.js +35 -0
  74. package/lib/utils/fileUtils.js.map +1 -1
  75. package/lib/utils/guid.js +8 -0
  76. package/lib/utils/guid.js.map +1 -1
  77. package/lib/utils/jsSource.js +187 -0
  78. package/lib/utils/jsSource.js.map +1 -0
  79. package/lib/vscode/browserContextFactory.js +87 -0
  80. package/lib/vscode/browserContextFactory.js.map +1 -0
  81. package/lib/vscode/host.js +189 -33
  82. package/lib/vscode/host.js.map +1 -1
  83. package/lib/vscode/main.js +3 -35
  84. package/lib/vscode/main.js.map +1 -1
  85. 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 _obtainBrowser(clientInfo) {
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
- this._browserPromise = this._doObtainBrowser(clientInfo);
51
- void this._browserPromise.then(browser => {
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 = undefined;
487
+ if (this._browserPromise === promise)
488
+ this._browserPromise = undefined;
54
489
  });
55
490
  }).catch(() => {
56
- this._browserPromise = undefined;
491
+ if (this._browserPromise === promise)
492
+ this._browserPromise = undefined;
57
493
  });
58
- return this._browserPromise;
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
- const browser = await this._obtainBrowser(clientInfo);
63
- const browserContext = await this._doCreateContext(browser);
64
- return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
65
- }
66
- async _closeBrowserContext(browserContext, browser) {
67
- testDebug(`close browser context (${this._logName})`);
68
- if (browser.contexts().length === 1)
69
- this._browserPromise = undefined;
70
- await browserContext.close().catch(logUnhandledError);
71
- if (browser.contexts().length === 0) {
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 injectCdpPort(this.config.browser);
592
+ const { cdpPortOptions, releaseCdpPort } = await allocateCdpPort(this.config.browser);
83
593
  const browserType = playwright[this.config.browser.browserName];
84
- return browserType.launch({
85
- tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
86
- ...this.config.browser.launchOptions,
87
- handleSIGINT: false,
88
- handleSIGTERM: false,
89
- }).catch(error => {
90
- if (error.message.includes('Executable doesn\'t exist'))
91
- throw browserNotInstalledError(error);
92
- throw error;
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
- const browser = await this._obtainBrowser(clientInfo);
106
- const browserContext = await this._doCreateContext(browser);
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
- testDebug('disconnect browser (cdp)');
111
- await browser.close().catch(logUnhandledError);
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
- return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
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
- const port = cdpLaunch.port ?? await findFreePort();
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
- const childProcess = spawn(cdpLaunch.command, args, {
152
- cwd: cdpLaunch.cwd,
153
- env: {
154
- ...process.env,
155
- ...cdpLaunch.env,
156
- },
157
- stdio: ['ignore', 'ignore', 'pipe'],
158
- });
159
- childProcess.stderr.on('data', data => {
160
- testDebug(`cdp-launch stderr: ${String(data).trimEnd()}`);
161
- });
162
- const browser = await this._waitForBrowser(endpoint, clientInfo, childProcess, cdpLaunch.startupTimeoutMs ?? 30000);
163
- const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
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
- class PersistentContextFactory {
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
- async createContext(clientInfo) {
203
- await injectCdpPort(this.config.browser);
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
- const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
206
- const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
207
- this._userDataDirs.add(userDataDir);
208
- testDebug('lock user data dir', userDataDir);
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
- for (let i = 0; i < 5; i++) {
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
- const browserContext = await browserType.launchPersistentContext(userDataDir, {
213
- tracesDir,
214
- ...this.config.browser.launchOptions,
215
- ...this.config.browser.contextOptions,
216
- handleSIGINT: false,
217
- handleSIGTERM: false,
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
- if (error.message.includes('Executable doesn\'t exist'))
224
- throw browserNotInstalledError(error);
225
- if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
226
- // User data directory is already in use, try again.
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
- throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
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
- async _createUserDataDir(rootPath) {
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
- // Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
246
- const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
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
- async function injectCdpPort(browserConfig) {
253
- if (browserConfig.browserName === 'chromium')
254
- browserConfig.launchOptions.cdpPort = await findFreePort();
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
- async function findFreePort() {
270
- return new Promise((resolve, reject) => {
271
- const server = net.createServer();
272
- server.listen(0, () => {
273
- const { port } = server.address();
274
- server.close(() => resolve(port));
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
- server.on('error', reject);
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
- async function startTraceServer(config, rootPath) {
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
- const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
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';