mcp-accessibility-scanner 3.0.0 → 3.1.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 +232 -7
- package/lib/browserContextFactory.js +645 -32
- package/lib/browserContextFactory.js.map +1 -1
- package/lib/browserServerBackend.js +8 -2
- package/lib/browserServerBackend.js.map +1 -1
- package/lib/context.js +179 -62
- package/lib/context.js.map +1 -1
- package/lib/extension/extensionContextFactory.js +3 -0
- package/lib/extension/extensionContextFactory.js.map +1 -1
- package/lib/mcp/proxyBackend.js +1 -0
- package/lib/mcp/proxyBackend.js.map +1 -1
- package/lib/networkPolicy.js +73 -0
- package/lib/networkPolicy.js.map +1 -0
- package/lib/program.js +21 -3
- package/lib/program.js.map +1 -1
- package/lib/response.js +8 -3
- package/lib/response.js.map +1 -1
- package/lib/tab.js +117 -16
- package/lib/tab.js.map +1 -1
- package/lib/tools/auditKeyboard.js +203 -0
- package/lib/tools/auditKeyboard.js.map +1 -1
- package/lib/tools/auditScreenReader.js +826 -0
- package/lib/tools/auditScreenReader.js.map +1 -0
- package/lib/tools/auditSite.js +267 -86
- 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/keyboard.js +1 -2
- package/lib/tools/keyboard.js.map +1 -1
- package/lib/tools/navigate.js +2 -1
- package/lib/tools/navigate.js.map +1 -1
- package/lib/tools/network.js +225 -8
- package/lib/tools/network.js.map +1 -1
- package/lib/tools/scanPageMatrix.js +134 -43
- package/lib/tools/scanPageMatrix.js.map +1 -1
- package/lib/tools/screenshot.js +3 -3
- package/lib/tools/screenshot.js.map +1 -1
- package/lib/tools/snapshot.js +269 -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 +5 -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/jsSource.js +187 -0
- package/lib/utils/jsSource.js.map +1 -0
- package/lib/vscode/browserContextFactory.js +83 -0
- package/lib/vscode/browserContextFactory.js.map +1 -0
- package/lib/vscode/host.js +16 -1
- package/lib/vscode/host.js.map +1 -1
- package/lib/vscode/main.js +1 -33
- package/lib/vscode/main.js.map +1 -1
- package/package.json +6 -4
|
@@ -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 } 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)
|
|
@@ -75,6 +479,7 @@ class BaseContextFactory {
|
|
|
75
479
|
}
|
|
76
480
|
}
|
|
77
481
|
class IsolatedContextFactory extends BaseContextFactory {
|
|
482
|
+
appliesStorageState = true;
|
|
78
483
|
constructor(config) {
|
|
79
484
|
super('isolated', config);
|
|
80
485
|
}
|
|
@@ -88,7 +493,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|
|
88
493
|
handleSIGTERM: false,
|
|
89
494
|
}).catch(error => {
|
|
90
495
|
if (error.message.includes('Executable doesn\'t exist'))
|
|
91
|
-
throw
|
|
496
|
+
throw browserNotInstalledError(error);
|
|
92
497
|
throw error;
|
|
93
498
|
});
|
|
94
499
|
}
|
|
@@ -97,18 +502,75 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|
|
97
502
|
}
|
|
98
503
|
}
|
|
99
504
|
class CdpContextFactory extends BaseContextFactory {
|
|
505
|
+
// The isolated path creates a fresh context with the state; the attach path
|
|
506
|
+
// applies it to the browser's existing context via setStorageState().
|
|
507
|
+
appliesStorageState = true;
|
|
508
|
+
// One attached browser — and its default context — serves every session
|
|
509
|
+
// this factory creates. Re-running the global setStorageState() for a
|
|
510
|
+
// second session would wipe the first session's live cookies and origin
|
|
511
|
+
// storage mid-audit and reload its pages, so the state is applied once per
|
|
512
|
+
// context object: later sessions join the live shared state. Keyed weakly —
|
|
513
|
+
// a reconnect yields a fresh context object, so the slate resets with the
|
|
514
|
+
// connection — and a failed apply is forgotten so the next session retries.
|
|
515
|
+
_storageStateApplied = new WeakMap();
|
|
516
|
+
// Serializes the no-context fallback the same way: two sessions arriving at
|
|
517
|
+
// a contextless target must share one created context, not race two.
|
|
518
|
+
_fallbackContext = new WeakMap();
|
|
100
519
|
constructor(config) {
|
|
101
520
|
super('cdp', config);
|
|
102
521
|
}
|
|
522
|
+
// The CDP connection (and with it every route and page proxy) is shared by
|
|
523
|
+
// all live sessions of this factory, so nothing may close it while a
|
|
524
|
+
// sibling session still audits through it — neither a session's own
|
|
525
|
+
// close() nor the cleanup after another session's failed setup. The
|
|
526
|
+
// handout count is kept per browser object, not per factory: an external
|
|
527
|
+
// disconnect makes _obtainBrowser hand out a fresh browser while stale
|
|
528
|
+
// sessions still hold references to the old one, and a shared counter
|
|
529
|
+
// would let a stale release keep the new connection open forever (its last
|
|
530
|
+
// real user would only ever bring the count down to the stale remainder).
|
|
531
|
+
// The reference is claimed before context creation, so a sibling still
|
|
532
|
+
// inside _doCreateContext() counts and a concurrent failure cannot close
|
|
533
|
+
// the connection out from under it.
|
|
534
|
+
_sessionCounts = new WeakMap();
|
|
535
|
+
_releaseBrowser(browser) {
|
|
536
|
+
const remaining = Math.max(0, (this._sessionCounts.get(browser) ?? 1) - 1);
|
|
537
|
+
this._sessionCounts.set(browser, remaining);
|
|
538
|
+
return remaining === 0;
|
|
539
|
+
}
|
|
103
540
|
async createContext(clientInfo) {
|
|
104
541
|
testDebug('create browser context (cdp)');
|
|
105
542
|
const browser = await this._obtainBrowser(clientInfo);
|
|
106
|
-
|
|
543
|
+
this._sessionCounts.set(browser, (this._sessionCounts.get(browser) ?? 0) + 1);
|
|
544
|
+
let browserContext;
|
|
545
|
+
try {
|
|
546
|
+
browserContext = await this._doCreateContext(browser);
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
// Without this the CDP connection stays open after e.g. an unreadable
|
|
550
|
+
// storage-state file, even though no context was ever handed out — but
|
|
551
|
+
// only when no sibling session is still using the shared connection.
|
|
552
|
+
if (this._releaseBrowser(browser))
|
|
553
|
+
await browser.close().catch(logUnhandledError);
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
let released = false;
|
|
107
557
|
return {
|
|
108
558
|
browserContext,
|
|
109
559
|
close: async () => {
|
|
110
|
-
|
|
111
|
-
|
|
560
|
+
if (released)
|
|
561
|
+
return;
|
|
562
|
+
released = true;
|
|
563
|
+
// An isolated session's context belongs to it alone — close it now,
|
|
564
|
+
// or abandoned contexts (with their pages, routes and listeners)
|
|
565
|
+
// pile up on a long-lived shared connection until the last session
|
|
566
|
+
// exits. The non-isolated context is the browser's own and shared;
|
|
567
|
+
// it stays.
|
|
568
|
+
if (this.config.browser.isolated)
|
|
569
|
+
await browserContext.close().catch(logUnhandledError);
|
|
570
|
+
if (this._releaseBrowser(browser)) {
|
|
571
|
+
testDebug('disconnect browser (cdp)');
|
|
572
|
+
await browser.close().catch(logUnhandledError);
|
|
573
|
+
}
|
|
112
574
|
}
|
|
113
575
|
};
|
|
114
576
|
}
|
|
@@ -120,10 +582,46 @@ class CdpContextFactory extends BaseContextFactory {
|
|
|
120
582
|
});
|
|
121
583
|
}
|
|
122
584
|
async _doCreateContext(browser) {
|
|
123
|
-
|
|
585
|
+
if (this.config.browser.isolated)
|
|
586
|
+
return await browser.newContext(this.config.browser.contextOptions);
|
|
587
|
+
const existing = browser.contexts()[0];
|
|
588
|
+
// An attached browser can expose no context at all; a fresh one created
|
|
589
|
+
// with the configured options (storage state included) beats handing an
|
|
590
|
+
// undefined context to the caller. The created context immediately seeds
|
|
591
|
+
// the applied-state memo — a later session will find it as the browser's
|
|
592
|
+
// existing context, and must join it rather than reset it — and the
|
|
593
|
+
// creation itself is memoized so concurrent arrivals share one context.
|
|
594
|
+
if (!existing) {
|
|
595
|
+
let creating = this._fallbackContext.get(browser);
|
|
596
|
+
if (!creating) {
|
|
597
|
+
creating = browser.newContext(this.config.browser.contextOptions).then(created => {
|
|
598
|
+
this._storageStateApplied.set(created, Promise.resolve());
|
|
599
|
+
// Evict on close, or a context closed externally (while the
|
|
600
|
+
// connection lives on) would keep being handed out of this memo to
|
|
601
|
+
// every later session — contexts() no longer lists it, so only the
|
|
602
|
+
// memo would remember it, forever.
|
|
603
|
+
created.on('close', () => this._fallbackContext.delete(browser));
|
|
604
|
+
return created;
|
|
605
|
+
});
|
|
606
|
+
this._fallbackContext.set(browser, creating);
|
|
607
|
+
creating.catch(() => this._fallbackContext.delete(browser));
|
|
608
|
+
}
|
|
609
|
+
return await creating;
|
|
610
|
+
}
|
|
611
|
+
// The shared promise also serializes two sessions arriving at once: both
|
|
612
|
+
// await the same application instead of racing two global resets.
|
|
613
|
+
let applied = this._storageStateApplied.get(existing);
|
|
614
|
+
if (!applied) {
|
|
615
|
+
applied = applyStorageStateToReusedContext(this.config, existing);
|
|
616
|
+
this._storageStateApplied.set(existing, applied);
|
|
617
|
+
applied.catch(() => this._storageStateApplied.delete(existing));
|
|
618
|
+
}
|
|
619
|
+
await applied;
|
|
620
|
+
return existing;
|
|
124
621
|
}
|
|
125
622
|
}
|
|
126
623
|
class RemoteContextFactory extends BaseContextFactory {
|
|
624
|
+
appliesStorageState = true;
|
|
127
625
|
constructor(config) {
|
|
128
626
|
super('remote', config);
|
|
129
627
|
}
|
|
@@ -135,11 +633,13 @@ class RemoteContextFactory extends BaseContextFactory {
|
|
|
135
633
|
return playwright[this.config.browser.browserName].connect(String(url));
|
|
136
634
|
}
|
|
137
635
|
async _doCreateContext(browser) {
|
|
138
|
-
return browser.newContext();
|
|
636
|
+
return browser.newContext(this.config.browser.contextOptions);
|
|
139
637
|
}
|
|
140
638
|
}
|
|
141
639
|
class CdpLaunchContextFactory {
|
|
142
640
|
config;
|
|
641
|
+
// See CdpContextFactory: fresh context when isolated, setStorageState otherwise.
|
|
642
|
+
appliesStorageState = true;
|
|
143
643
|
constructor(config) {
|
|
144
644
|
this.config = config;
|
|
145
645
|
}
|
|
@@ -160,7 +660,32 @@ class CdpLaunchContextFactory {
|
|
|
160
660
|
testDebug(`cdp-launch stderr: ${String(data).trimEnd()}`);
|
|
161
661
|
});
|
|
162
662
|
const browser = await this._waitForBrowser(endpoint, clientInfo, childProcess, cdpLaunch.startupTimeoutMs ?? 30000);
|
|
163
|
-
|
|
663
|
+
let browserContext;
|
|
664
|
+
try {
|
|
665
|
+
if (this.config.browser.isolated) {
|
|
666
|
+
browserContext = await browser.newContext(this.config.browser.contextOptions);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
const existing = browser.contexts()[0];
|
|
670
|
+
if (existing) {
|
|
671
|
+
await applyStorageStateToReusedContext(this.config, existing);
|
|
672
|
+
browserContext = existing;
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
// See CdpContextFactory: a launched app can expose no context yet;
|
|
676
|
+
// a fresh one with the configured options beats an undefined one.
|
|
677
|
+
browserContext = await browser.newContext(this.config.browser.contextOptions);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
// The desktop process is already running by now; failing to obtain a
|
|
683
|
+
// context (say, an unreadable storage-state file) must not leave it and
|
|
684
|
+
// the CDP connection behind with nobody holding a close() for them.
|
|
685
|
+
await browser.close().catch(logUnhandledError);
|
|
686
|
+
childProcess.kill('SIGTERM');
|
|
687
|
+
throw error;
|
|
688
|
+
}
|
|
164
689
|
return {
|
|
165
690
|
browserContext,
|
|
166
691
|
close: async () => {
|
|
@@ -191,8 +716,18 @@ class CdpLaunchContextFactory {
|
|
|
191
716
|
}
|
|
192
717
|
}
|
|
193
718
|
}
|
|
194
|
-
|
|
719
|
+
// Distinguishes a storage-state failure from a launch failure, so the launch
|
|
720
|
+
// retry loop never retries on one (a bad state file fails the same way 5 times).
|
|
721
|
+
class StorageStateError extends Error {
|
|
722
|
+
}
|
|
723
|
+
// Shared with the --connect-tool startup validation in program.ts, so the
|
|
724
|
+
// lazy rejection here and the eager one there never drift apart.
|
|
725
|
+
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.';
|
|
726
|
+
export class PersistentContextFactory {
|
|
195
727
|
config;
|
|
728
|
+
// launchPersistentContext() silently ignores a storageState option, so the
|
|
729
|
+
// state is applied to the launched context with setStorageState() instead.
|
|
730
|
+
appliesStorageState = true;
|
|
196
731
|
name = 'persistent';
|
|
197
732
|
description = 'Create a new persistent browser context';
|
|
198
733
|
_userDataDirs = new Set();
|
|
@@ -202,49 +737,114 @@ class PersistentContextFactory {
|
|
|
202
737
|
async createContext(clientInfo) {
|
|
203
738
|
await injectCdpPort(this.config.browser);
|
|
204
739
|
testDebug('create browser context (persistent)');
|
|
205
|
-
|
|
740
|
+
// launchPersistentContext() accepts a storageState option without applying
|
|
741
|
+
// it (verified against 1.61.1) — the profile is normally the state — so it
|
|
742
|
+
// is stripped here and applied explicitly after launch.
|
|
743
|
+
const { storageState, ...contextOptions } = this.config.browser.contextOptions ?? {};
|
|
744
|
+
// setStorageState() resets cookies globally but origin storage only for
|
|
745
|
+
// origins in the state or known to the fresh context object, so stale
|
|
746
|
+
// localStorage/IndexedDB in a previously used profile would survive and
|
|
747
|
+
// could sign the audit in as the wrong identity. A storage-state session
|
|
748
|
+
// therefore runs in its own fresh disposable profile — unique per context,
|
|
749
|
+
// because one server can hold several live sessions and a shared
|
|
750
|
+
// deterministic directory would let one session's setup destroy another's
|
|
751
|
+
// running profile — removed again when the context closes. A user-supplied
|
|
752
|
+
// profile cannot be treated this way — it is data we do not own — and
|
|
753
|
+
// keeping it contradicts "start from the recorded state", so that
|
|
754
|
+
// combination errors.
|
|
755
|
+
assertStorageStateDoesNotResetUserProfile(this.config, persistentProfileConflictRemedy);
|
|
756
|
+
// Trace setup runs before the disposable profile exists: it can fail (an
|
|
757
|
+
// unwritable output directory), and nothing after the directory is created
|
|
758
|
+
// may throw outside the cleanup scope below, or failed starts would leave
|
|
759
|
+
// stray profiles behind.
|
|
206
760
|
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
|
|
761
|
+
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath, storageState ? `-storage-state-${createGuid()}` : '');
|
|
207
762
|
this._userDataDirs.add(userDataDir);
|
|
208
763
|
testDebug('lock user data dir', userDataDir);
|
|
209
764
|
const browserType = playwright[this.config.browser.browserName];
|
|
210
|
-
|
|
765
|
+
try {
|
|
766
|
+
for (let i = 0; i < 5; i++) {
|
|
767
|
+
try {
|
|
768
|
+
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
|
769
|
+
tracesDir,
|
|
770
|
+
...this.config.browser.launchOptions,
|
|
771
|
+
...contextOptions,
|
|
772
|
+
handleSIGINT: false,
|
|
773
|
+
handleSIGTERM: false,
|
|
774
|
+
});
|
|
775
|
+
return await this._applyStorageState(browserContext, storageState, userDataDir);
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
if (error instanceof StorageStateError)
|
|
779
|
+
throw error;
|
|
780
|
+
if (error.message.includes('Executable doesn\'t exist'))
|
|
781
|
+
throw browserNotInstalledError(error);
|
|
782
|
+
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
|
783
|
+
// User data directory is already in use, try again.
|
|
784
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
// The disposable profile belongs to this context alone, so a launch that
|
|
794
|
+
// never produced a context must not leave it behind — repeated failed
|
|
795
|
+
// starts would otherwise pile one stray directory into the registry each.
|
|
796
|
+
// (Already removed on the StorageStateError path; rm is idempotent.)
|
|
797
|
+
if (storageState) {
|
|
798
|
+
await fs.promises.rm(userDataDir, { recursive: true, force: true }).catch(() => { });
|
|
799
|
+
this._userDataDirs.delete(userDataDir);
|
|
800
|
+
}
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// Separate from the launch retry loop: its `catch` retries on messages a
|
|
805
|
+
// malformed storage-state file could coincidentally match (`Invalid URL`).
|
|
806
|
+
async _applyStorageState(browserContext, storageState, userDataDir) {
|
|
807
|
+
if (storageState) {
|
|
211
808
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
|
220
|
-
return { browserContext, close };
|
|
809
|
+
// Startup pages can keep persisting their anonymous identity while the
|
|
810
|
+
// state lands. Park them on blank replacements before the apply, then
|
|
811
|
+
// navigate the replacements only after the recorded state is installed.
|
|
812
|
+
const replaced = await replaceOpenPagesWithBlankTabs(browserContext);
|
|
813
|
+
await browserContext.setStorageState(storageState);
|
|
814
|
+
await ensureNetworkPolicyRoutes(this.config, browserContext);
|
|
815
|
+
await navigateReplacementPages(replaced);
|
|
221
816
|
}
|
|
222
817
|
catch (error) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
throw error;
|
|
818
|
+
// Nobody holds a close() for this context yet, so a bad storage-state
|
|
819
|
+
// file must not leave the launched browser running.
|
|
820
|
+
await this._closeBrowserContext(browserContext, userDataDir, true);
|
|
821
|
+
throw new StorageStateError(error instanceof Error ? error.message : String(error));
|
|
231
822
|
}
|
|
232
823
|
}
|
|
233
|
-
|
|
824
|
+
const close = () => this._closeBrowserContext(browserContext, userDataDir, !!storageState);
|
|
825
|
+
return { browserContext, close };
|
|
234
826
|
}
|
|
235
|
-
async _closeBrowserContext(browserContext, userDataDir) {
|
|
827
|
+
async _closeBrowserContext(browserContext, userDataDir, disposeUserDataDir = false) {
|
|
236
828
|
testDebug('close browser context (persistent)');
|
|
237
829
|
testDebug('release user data dir', userDataDir);
|
|
238
830
|
await browserContext.close().catch(() => { });
|
|
831
|
+
// A storage-state profile is unique to this context and holds nothing worth
|
|
832
|
+
// keeping — the state file is the durable copy — so it is removed rather
|
|
833
|
+
// than left to pile up next to the regular persistent profile.
|
|
834
|
+
if (disposeUserDataDir)
|
|
835
|
+
await fs.promises.rm(userDataDir, { recursive: true, force: true }).catch(() => { });
|
|
239
836
|
this._userDataDirs.delete(userDataDir);
|
|
240
837
|
testDebug('close browser context complete (persistent)');
|
|
241
838
|
}
|
|
242
|
-
|
|
839
|
+
// The suffix keeps disposable storage-state profiles apart from the regular
|
|
840
|
+
// persistent profile (and, carrying a per-context guid, from each other), so
|
|
841
|
+
// removing one can never destroy an interactive session or a sibling's.
|
|
842
|
+
async _createUserDataDir(rootPath, suffix) {
|
|
243
843
|
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
|
|
244
844
|
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
|
|
245
845
|
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
|
|
246
846
|
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
|
|
247
|
-
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
|
|
847
|
+
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}${suffix}`);
|
|
248
848
|
await fs.promises.mkdir(result, { recursive: true });
|
|
249
849
|
return result;
|
|
250
850
|
}
|
|
@@ -276,6 +876,19 @@ async function findFreePort() {
|
|
|
276
876
|
server.on('error', reject);
|
|
277
877
|
});
|
|
278
878
|
}
|
|
879
|
+
/**
|
|
880
|
+
* Builds the user-facing "browser not installed" error from Playwright's raw
|
|
881
|
+
* launch failure. When the raw message carries a version-specific executable
|
|
882
|
+
* path (e.g. `chromium-1234`), that path is surfaced so a version mismatch is
|
|
883
|
+
* distinguishable from a genuinely missing install; otherwise the generic
|
|
884
|
+
* message is returned unchanged. Mirrors Playwright MCP throwIfExecutableMissing
|
|
885
|
+
* (microsoft/playwright#41941).
|
|
886
|
+
*/
|
|
887
|
+
function browserNotInstalledError(error) {
|
|
888
|
+
const match = error.message.match(/Executable doesn't exist at ([^\r\n]+)/);
|
|
889
|
+
const location = match ? `; expected executable at ${match[1].trim()}` : '';
|
|
890
|
+
return new Error(`Browser specified in your config is not installed${location}. Either install it (likely) or change the config.`);
|
|
891
|
+
}
|
|
279
892
|
async function startTraceServer(config, rootPath) {
|
|
280
893
|
if (!config.saveTrace)
|
|
281
894
|
return undefined;
|