diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 91ceabfbf895d..a94cdbe4a0ba3 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1803,13 +1803,6 @@ var banana = await page.GetByRole(AriaRole.Listitem).Last(1); ### option: Locator.locator.hasNotText = %%-locator-option-has-not-text-%% * since: v1.33 -## async method: Locator.normalize -* since: v1.59 -- returns: <[Locator]> - -Returns a new locator that uses best practices for referencing the matched element, prioritizing test ids, -aria roles, and other user-facing attributes over CSS selectors. This is useful for converting implementation-detail selectors into more resilient, human-readable locators. - ## method: Locator.nth * since: v1.14 - returns: <[Locator]> @@ -2550,6 +2543,12 @@ If you need to assert text on the page, prefer [`method: LocatorAssertions.toHav ### option: Locator.textContent.timeout = %%-input-timeout-js-%% * since: v1.14 +## async method: Locator.toCode +* since: v1.59 +- returns: <[string]> + +Returns a code string for a locator that uses best practices for referencing the matched element, prioritizing test ids, aria roles, and other user-facing attributes over CSS selectors. + ## method: Locator.toString * since: v1.57 * langs: js diff --git a/eslint.config.mjs b/eslint.config.mjs index 4c70f39d8cdf3..e9875fbfae903 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -355,6 +355,20 @@ export default [ ...noFloatingPromisesRules, }, }, + { + files: ["packages/playwright-core/src/tools/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [{ + group: ["**/client", "**/client/**"], + message: "tools/ must not import from client/", + }], + }, + ], + }, + }, { files: [ "packages/playwright-core/src/utils/**/*.ts", diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 21d6444d37a0f..0f3a236cf35c9 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -14190,13 +14190,6 @@ export interface Locator { hasText?: string|RegExp; }): Locator; - /** - * Returns a new locator that uses best practices for referencing the matched element, prioritizing test ids, aria - * roles, and other user-facing attributes over CSS selectors. This is useful for converting implementation-detail - * selectors into more resilient, human-readable locators. - */ - normalize(): Promise; - /** * Returns locator to the n-th matching element. It's zero based, `nth(0)` selects the first element. * @@ -14762,6 +14755,12 @@ export interface Locator { timeout?: number; }): Promise; + /** + * Returns a code string for a locator that uses best practices for referencing the matched element, prioritizing test + * ids, aria roles, and other user-facing attributes over CSS selectors. + */ + toCode(): Promise; + /** * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and `keyup` event for each character in the * text. diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index d9f240084f8e5..5390ba6143072 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -24,7 +24,7 @@ import { mkdirIfNeeded } from './fileUtils'; import type { BrowserType } from './browserType'; import type { Page } from './page'; -import type { BrowserContextOptions, LaunchOptions, Logger, StartServerOptions } from './types'; +import type { BrowserContextOptions, LaunchOptions, Logger } from './types'; import type * as api from '../../types/types'; import type * as channels from '@protocol/channels'; @@ -130,11 +130,12 @@ export class Browser extends ChannelOwner implements ap return this._initializer.version; } - async _startServer(title: string, options: StartServerOptions = {}): Promise<{ wsEndpoint?: string, pipeName?: string }> { - return await this._channel.startServer({ title, ...options }); + async _register(title: string, options: { workspaceDir?: string, metadata?: Record, wsPath?: string } = {}): Promise<{ pipeName: string }> { + const { pipeName } = await this._channel.startServer({ title, ...options }); + return { pipeName }; } - async _stopServer(): Promise { + async _unregister(): Promise { await this._channel.stopServer(); } diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index dac603d94fd24..c0b5b49a8a964 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -79,8 +79,7 @@ export class BrowserContext extends ChannelOwner private _closeReason: string | undefined; private _harRouters: HarRouter[] = []; private _onRecorderEventSink: RecorderEventSink | undefined; - private _disallowedProtocols: string[] | undefined; - private _allowedDirectories: string[] | undefined; + static from(context: channels.BrowserContextChannel): BrowserContext { return (context as any)._object; @@ -97,7 +96,6 @@ export class BrowserContext extends ChannelOwner this.tracing = Tracing.from(initializer.tracing); this.request = APIRequestContext.from(initializer.requestContext); this.request._timeoutSettings = this._timeoutSettings; - this.request._checkUrlAllowed = (url: string) => this._checkUrlAllowed(url); this.clock = new Clock(this); this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding))); @@ -559,40 +557,6 @@ export class BrowserContext extends ChannelOwner await this._channel.exposeConsoleApi(); } - _setDisallowedProtocols(protocols: string[]) { - this._disallowedProtocols = protocols; - } - - _checkUrlAllowed(url: string) { - if (!this._disallowedProtocols) - return; - let parsedURL; - try { - parsedURL = new URL(url); - } catch (e) { - throw new Error(`Access to ${url} is blocked. Invalid URL: ${e.message}`); - } - if (this._disallowedProtocols.includes(parsedURL.protocol)) - throw new Error(`Access to "${parsedURL.protocol}" protocol is blocked. Attempted URL: "${url}"`); - } - - _setAllowedDirectories(rootDirectories: string[]) { - this._allowedDirectories = rootDirectories; - } - - _checkFileAccess(filePath: string) { - if (!this._allowedDirectories) - return; - const path = this._platform.path().resolve(filePath); - const isInsideDir = (container: string, child: string): boolean => { - const path = this._platform.path(); - const rel = path.relative(container, child); - return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); - }; - if (this._allowedDirectories.some(root => isInsideDir(root, path))) - return; - throw new Error(`File access denied: ${filePath} is outside allowed roots. Allowed roots: ${this._allowedDirectories.length ? this._allowedDirectories.join(', ') : 'none'}`); - } } async function prepareStorageState(platform: Platform, storageState: string | SetStorageState): Promise> { diff --git a/packages/playwright-core/src/client/connect.ts b/packages/playwright-core/src/client/connect.ts index e22200975cdcb..15e1da7f6f90b 100644 --- a/packages/playwright-core/src/client/connect.ts +++ b/packages/playwright-core/src/client/connect.ts @@ -21,11 +21,9 @@ import { ChannelOwner } from './channelOwner'; import { Connection } from './connection'; import { Events } from './events'; -import type * as playwright from '../..'; import type { Playwright } from './playwright'; import type { ConnectOptions, HeadersArray } from './types'; import type * as channels from '@protocol/channels'; -import type { BrowserDescriptor } from '../serverRegistry'; export async function connectToBrowser(playwright: Playwright, params: ConnectOptions): Promise { const deadline = params.timeout ? monotonicTime() + params.timeout : 0; @@ -101,14 +99,6 @@ export async function connectToEndpoint(parentConnection: Connection, params: ch return connection; } -export async function connectToBrowserAcrossVersions(descriptor: BrowserDescriptor): Promise { - const pw = require(descriptor.playwrightLib); - const params: ConnectOptions = { endpoint: descriptor.pipeName! }; - const browser = await connectToBrowser(pw, params); - browser._connectToBrowserType(pw[descriptor.browser.browserName], {}, undefined); - return browser; -} - interface Transport { connect(params: channels.LocalUtilsConnectParams): Promise; send(message: any): Promise; diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index 4eb1d52f8938f..c256300981cd3 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -284,10 +284,6 @@ export async function convertInputFiles(platform: Platform, files: string | File const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(platform, items); - localPaths?.forEach(path => context._checkFileAccess(path)); - if (localDirectory) - context._checkFileAccess(localDirectory); - if (context._connection.isRemote()) { const files = localDirectory ? (await platform.fs().promises.readdir(localDirectory, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => platform.path().join(f.parentPath, f.name)) : localPaths!; const { writableStreams, rootDir } = await context._wrapApiCall(async () => context._channel.createTempFiles({ diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index ec32805eed02d..efdece221165c 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -91,7 +91,6 @@ export class APIRequestContext extends ChannelOwner void; static from(channel: channels.APIRequestContextChannel): APIRequestContext { return (channel as any)._object; @@ -178,7 +177,6 @@ export class APIRequestContext extends ChannelOwner= 0, `'maxRedirects' must be greater than or equal to '0'`); assert(options.maxRetries === undefined || options.maxRetries >= 0, `'maxRetries' must be greater than or equal to '0'`); const url = options.url !== undefined ? options.url : options.request!.url(); - this._checkUrlAllowed?.(url); const method = options.method || options.request?.method(); let encodedParams = undefined; if (typeof options.params === 'string') diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index b6eca9358412c..863ff31308404 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -110,7 +110,6 @@ export class Frame extends ChannelOwner implements api.Fr async goto(url: string, options: channels.FrameGotoOptions & TimeoutOptions = {}): Promise { const waitUntil = verifyLoadState('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); - this.page().context()._checkUrlAllowed(url); return network.Response.fromNullable((await this._channel.goto({ url, ...options, waitUntil, timeout: this._navigationTimeout(options) })).response); } diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 5c30024eb3d49..88af0b2eeac67 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -254,9 +254,9 @@ export class Locator implements api.Locator { return await this._frame._queryCount(this._selector, _options); } - async normalize(): Promise { + async toCode(): Promise { const { resolvedSelector } = await this._frame._channel.resolveSelector({ selector: this._selector }); - return new Locator(this._frame, resolvedSelector); + return new Locator(this._frame, resolvedSelector).toString(); } async getAttribute(name: string, options?: TimeoutOptions): Promise { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index b1338f5d2de3c..eff128b98f921 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -652,15 +652,11 @@ scheme.BrowserContextEvent = tObject({ scheme.BrowserCloseEvent = tOptional(tObject({})); scheme.BrowserStartServerParams = tObject({ title: tString, - host: tOptional(tString), - port: tOptional(tInt), - wsPath: tOptional(tString), workspaceDir: tOptional(tString), metadata: tOptional(tAny), }); scheme.BrowserStartServerResult = tObject({ - wsEndpoint: tOptional(tString), - pipeName: tOptional(tString), + pipeName: tString, }); scheme.BrowserStopServerParams = tOptional(tObject({})); scheme.BrowserStopServerResult = tOptional(tObject({})); diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index ebc4ea14aa8a4..0ebca4d41bce5 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -174,7 +174,7 @@ export abstract class Browser extends SdkObject { return video?.artifact; } - async startServer(title: string, options: channels.BrowserStartServerOptions): Promise<{ wsEndpoint?: string, pipeName?: string }> { + async startServer(title: string, options: channels.BrowserStartServerOptions): Promise<{ pipeName: string }> { return await this._server.start(title, options); } @@ -219,22 +219,15 @@ export class BrowserServer { this._browser = browser; } - async start(title: string, options: channels.BrowserStartServerOptions): Promise<{ wsEndpoint?: string, pipeName?: string }> { + async start(title: string, options: channels.BrowserStartServerOptions): Promise<{ pipeName: string }> { if (this._isStarted) throw new Error(`Server is already started.`); this._isStarted = true; - const result: { wsEndpoint?: string, pipeName?: string } = {}; this._pipeServer = new PlaywrightPipeServer(this._browser); this._pipeSocketPath = await this._socketPath(); await this._pipeServer.listen(this._pipeSocketPath); - result.pipeName = this._pipeSocketPath; - - if (options.wsPath) { - const path = options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`; - this._wsServer = new PlaywrightWebSocketServer(this._browser, path); - result.wsEndpoint = await this._wsServer.listen(options.port ?? 0, options.host ?? 'localhost', path); - } + const pipeName = this._pipeSocketPath; const browserInfo: BrowserInfo = { guid: this._browser.guid, @@ -244,12 +237,11 @@ export class BrowserServer { }; await serverRegistry.create(browserInfo, { title, - wsEndpoint: result.wsEndpoint, - pipeName: result.pipeName, + pipeName, workspaceDir: options.workspaceDir, metadata: options.metadata, }); - return result; + return { pipeName }; } async stop() { diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 6fa101c70bc2e..7eb31ab09d48b 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -284,10 +284,6 @@ export class Context { if (this.config.testIdAttribute) selectors.setTestIdAttribute(this.config.testIdAttribute); const browserContext = this._rawBrowserContext; - if (!this.config.allowUnrestrictedFileAccess) { - (browserContext as any)._setDisallowedProtocols(['file:']); - (browserContext as any)._setAllowedDirectories([this.options.cwd]); - } await this._setupRequestInterception(browserContext); if (this.config.saveTrace) { @@ -313,6 +309,15 @@ export class Context { return browserContext; } + checkUrlAllowed(url: string) { + if (this.config.allowUnrestrictedFileAccess) + return; + if (!URL.canParse(url)) + return; + if (new URL(url).protocol === 'file:') + throw new Error(`Access to "file:" protocol is blocked. Attempted URL: "${url}"`); + } + lookupSecret(secretName: string): { value: string, code: string } { if (!this.config.secrets?.[secretName]) return { value: secretName, code: escapeWithQuotes(secretName, '\'') }; @@ -343,7 +348,7 @@ function originOrHostGlob(originOrHost: string) { export async function workspaceFile(options: ContextOptions, fileName: string, perCallWorkspaceDir?: string): Promise { const workspace = perCallWorkspaceDir ?? options.cwd; const resolvedName = path.resolve(workspace, fileName); - await checkFile(options, resolvedName, { origin: 'code' }); + await checkFile(options, resolvedName, { origin: 'llm' }); return resolvedName; } @@ -362,13 +367,13 @@ export async function outputFile(options: ContextOptions, fileName: string, flag } async function checkFile(options: ContextOptions, resolvedFilename: string, flags: { origin: 'code' | 'llm' }) { - // Trust code. - if (flags.origin === 'code') + // Trust code and unrestricted file access. + if (flags.origin === 'code' || options.config.allowUnrestrictedFileAccess) return; // Trust llm to use valid characters in file names. const output = outputDir(options); const workspace = options.cwd; if (!resolvedFilename.startsWith(output) && !resolvedFilename.startsWith(workspace)) - throw new Error(`Resolved file path ${resolvedFilename} is outside of the output directory ${output} and workspace directory ${workspace}. Use relative file names to stay within the output directory.`); + throw new Error(`File access denied: ${resolvedFilename} is outside allowed roots. Allowed roots: ${output}, ${workspace}`); } diff --git a/packages/playwright-core/src/tools/backend/files.ts b/packages/playwright-core/src/tools/backend/files.ts index d872d35bdbafe..3a59160eb9849 100644 --- a/packages/playwright-core/src/tools/backend/files.ts +++ b/packages/playwright-core/src/tools/backend/files.ts @@ -37,6 +37,9 @@ export const uploadFile = defineTabTool({ if (!modalState) throw new Error('No file chooser visible'); + if (params.paths) + await Promise.all(params.paths.map(filePath => response.resolveClientFilename(filePath))); + response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`); tab.clearModalState(modalState); diff --git a/packages/playwright-core/src/tools/backend/navigate.ts b/packages/playwright-core/src/tools/backend/navigate.ts index 8a5f37a856aed..3e214cc425ea0 100644 --- a/packages/playwright-core/src/tools/backend/navigate.ts +++ b/packages/playwright-core/src/tools/backend/navigate.ts @@ -42,6 +42,7 @@ const navigate = defineTool({ url = 'https://' + url; } + context.checkUrlAllowed(url); await tab.navigate(url); response.setIncludeSnapshot(); diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index 6d6593303bdc3..bcb7c23024f51 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -68,7 +68,7 @@ export class Response { async resolveClientFile(template: FilenameTemplate, title: string): Promise { let fileName: string; if (template.suggestedFilename) - fileName = await this._context.workspaceFile(template.suggestedFilename, this._clientWorkspace); + fileName = await this.resolveClientFilename(template.suggestedFilename); else fileName = await this._context.outputFile(template, { origin: 'llm' }); const relativeName = this._computRelativeTo(fileName); @@ -76,6 +76,10 @@ export class Response { return { fileName, relativeName, printableLink }; } + async resolveClientFilename(filename: string): Promise { + return await this._context.workspaceFile(filename, this._clientWorkspace); + } + addTextResult(text: string) { this._results.push(text); } diff --git a/packages/playwright-core/src/tools/backend/tab.ts b/packages/playwright-core/src/tools/backend/tab.ts index cf78e6005d1a3..c6eebd34138f1 100644 --- a/packages/playwright-core/src/tools/backend/tab.ts +++ b/packages/playwright-core/src/tools/backend/tab.ts @@ -23,7 +23,7 @@ import { debug } from '../../utilsBundle'; import { eventsHelper } from '../../server/utils/eventsHelper'; import { disposeAll } from '../../server/utils/disposable'; -import { callOnPageNoTrace, waitForCompletion, eventWaiter } from './utils'; +import { waitForCompletion, eventWaiter } from './utils'; import { LogFile } from './logFile'; import { ModalState } from './tool'; import { handleDialog } from './dialogs'; @@ -268,7 +268,7 @@ export class Tab extends EventEmitter { async headerSnapshot(): Promise { let title: string | undefined; await this._raceAgainstModalStates(async () => { - title = await callOnPageNoTrace(this.page, page => page.title()); + title = await this.page.title(); }); const newHeader: TabHeader = { title: title ?? '', @@ -290,7 +290,7 @@ export class Tab extends EventEmitter { async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise { await this._initializedPromise; - await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(e => debug('pw:tools:error')(e))); + await this.page.waitForLoadState(state, options).catch(e => debug('pw:tools:error')(e)); } async navigate(url: string) { @@ -437,19 +437,16 @@ export class Tab extends EventEmitter { return Promise.all(params.map(async param => { if (param.selector) { const locator = this.page.locator(param.selector); - try { - await locator.normalize(); - } catch { + if (!await locator.isVisible()) throw new Error(`Selector ${param.selector} does not match any elements.`); - } return { locator, resolved: asLocator('javascript', param.selector) }; } else { try { let locator = this.page.locator(`aria-ref=${param.ref}`); if (param.element) locator = locator.describe(param.element); - const resolved = await locator.normalize(); - return { locator, resolved: resolved.toString() }; + const resolved = await locator.toCode(); + return { locator, resolved }; } catch (e) { throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`); } @@ -463,9 +460,7 @@ export class Tab extends EventEmitter { return; } - await callOnPageNoTrace(this.page, page => { - return page.evaluate(() => new Promise(f => setTimeout(f, 1000))).catch(() => {}); - }); + await this.page.evaluate(() => new Promise(f => setTimeout(f, 1000))).catch(() => {}); } } diff --git a/packages/playwright-core/src/tools/backend/utils.ts b/packages/playwright-core/src/tools/backend/utils.ts index 918dc0e7d1c21..b235de4d9b83a 100644 --- a/packages/playwright-core/src/tools/backend/utils.ts +++ b/packages/playwright-core/src/tools/backend/utils.ts @@ -55,10 +55,6 @@ export async function waitForCompletion(tab: Tab, callback: () => Promise) return result; } -export async function callOnPageNoTrace(page: playwright.Page, callback: (page: playwright.Page) => Promise): Promise { - return await (page as any)._wrapApiCall(() => callback(page), { internal: true }); -} - export function eventWaiter(page: playwright.Page, event: string, timeout: number): { promise: Promise, abort: () => void } { const disposables: (() => void)[] = []; diff --git a/packages/playwright-core/src/tools/backend/verify.ts b/packages/playwright-core/src/tools/backend/verify.ts index 5e53bf68b5b3e..b1535756793d3 100644 --- a/packages/playwright-core/src/tools/backend/verify.ts +++ b/packages/playwright-core/src/tools/backend/verify.ts @@ -36,7 +36,7 @@ const verifyElement = defineTabTool({ for (const frame of tab.page.frames()) { const locator = frame.getByRole(params.role as any, { name: params.accessibleName }); if (await locator.count() > 0) { - const resolved = await locator.normalize(); + const resolved = await locator.toCode(); response.addCode(`await expect(page.${resolved}).toBeVisible();`); response.addTextResult('Done'); return; @@ -62,7 +62,7 @@ const verifyText = defineTabTool({ for (const frame of tab.page.frames()) { const locator = frame.getByText(params.text).filter({ visible: true }); if (await locator.count() > 0) { - const resolved = await locator.normalize(); + const resolved = await locator.toCode(); response.addCode(`await expect(page.${resolved}).toBeVisible();`); response.addTextResult('Done'); return; diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts index 54cabf1f7e9f6..97320860b9c3c 100644 --- a/packages/playwright-core/src/tools/cli-client/program.ts +++ b/packages/playwright-core/src/tools/cli-client/program.ts @@ -344,17 +344,26 @@ async function listSessions(registry: Registry, clientInfo: ClientInfo, all: boo return; } + const runningSessions = new Set(); if (entries.size) console.log('### Browsers'); for (const [workspace, list] of entries) - await gcAndPrintSessions(clientInfo, list.map(entry => new Session(entry)), `${path.relative(process.cwd(), workspace) || '/'}:`); + await gcAndPrintSessions(clientInfo, list.map(entry => new Session(entry)), `${path.relative(process.cwd(), workspace) || '/'}:`, runningSessions); + + // Filter out server entries that already have an attached session. + const filteredServerEntries = new Map(); + for (const [workspace, list] of serverEntries) { + const unattached = list.filter(d => !runningSessions.has(d.title)); + if (unattached.length) + filteredServerEntries.set(workspace, unattached); + } - if (serverEntries.size) { + if (filteredServerEntries.size) { if (entries.size) console.log(''); console.log('### Browser servers available for attach'); } - for (const [workspace, list] of serverEntries) + for (const [workspace, list] of filteredServerEntries) await gcAndPrintBrowserSessions(workspace, list); } else { console.log('### Browsers'); @@ -363,7 +372,7 @@ async function listSessions(registry: Registry, clientInfo: ClientInfo, all: boo } } -async function gcAndPrintSessions(clientInfo: ClientInfo, sessions: Session[], header?: string) { +async function gcAndPrintSessions(clientInfo: ClientInfo, sessions: Session[], header?: string, runningSessions?: Set) { const running: Session[] = []; const stopped: Session[] = []; @@ -371,6 +380,7 @@ async function gcAndPrintSessions(clientInfo: ClientInfo, sessions: Session[], h const canConnect = await session.canConnect(); if (canConnect) { running.push(session); + runningSessions?.add(session.name); } else { if (session.config.cli.persistent) stopped.push(session); diff --git a/packages/playwright-core/src/tools/cli-client/session.ts b/packages/playwright-core/src/tools/cli-client/session.ts index b18fb9a9b5ef3..c0af5595a5d86 100644 --- a/packages/playwright-core/src/tools/cli-client/session.ts +++ b/packages/playwright-core/src/tools/cli-client/session.ts @@ -147,8 +147,8 @@ export class Session { args.push(`--profile=${cliArgs.profile}`); if (cliArgs.config) args.push(`--config=${cliArgs.config}`); - if (cliArgs.attach) - args.push(`--attach=${cliArgs.attach}`); + if (cliArgs.attach || process.env.PLAYWRIGHT_CLI_SESSION) + args.push(`--attach=${cliArgs.attach || process.env.PLAYWRIGHT_CLI_SESSION}`); const child = spawn(process.execPath, args, { detached: true, diff --git a/packages/playwright-core/src/tools/cli-daemon/program.ts b/packages/playwright-core/src/tools/cli-daemon/program.ts index deca0cf292a4a..b301cecf485d0 100644 --- a/packages/playwright-core/src/tools/cli-daemon/program.ts +++ b/packages/playwright-core/src/tools/cli-daemon/program.ts @@ -41,10 +41,14 @@ program.argument('[session-name]', 'name of the session to create or connect to' setupExitWatchdog(); const clientInfo = createClientInfo(); const mcpConfig = await resolveCLIConfig(clientInfo, sessionName, options); - const mcpClientInfo = { cwd: process.cwd() }; + const clientInfoEx = { + cwd: process.cwd(), + sessionName, + workspaceDir: clientInfo.workspaceDir, + }; try { - const browser = await createBrowser(mcpConfig, mcpClientInfo); + const browser = await createBrowser(mcpConfig, clientInfoEx); const browserContext = mcpConfig.browser.isolated ? await browser.newContext(mcpConfig.browser.contextOptions) : browser.contexts()[0]; if (!browserContext) throw new Error('Error: unable to connect to a browser that does not have any contexts'); @@ -52,11 +56,6 @@ program.argument('[session-name]', 'name of the session to create or connect to' const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { persistent, exitOnClose: true }); console.log(`### Success\nDaemon listening on ${socketPath}`); console.log(''); - - if (!(browser as any)._connection.isRemote()) { - await (browser as any)._startServer(sessionName, { workspaceDir: clientInfo.workspaceDir }); - browserContext.on('close', () => (browser as any)._stopServer().catch(() => {})); - } } catch (error) { const message = process.env.PWDEBUGIMPL ? (error as Error).stack || (error as Error).message : (error as Error).message; console.log(`### Error\n${message}`); diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index 5c7690b1fe55f..becbc59342c32 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -27,7 +27,7 @@ import { findChromiumChannelBestEffort, registryDirectory } from '../../server/r import { calculateSha1 } from '../../utils'; import { CDPConnection, DashboardConnection } from './dashboardController'; import { serverRegistry } from '../../serverRegistry'; -import { connectToBrowserAcrossVersions } from '../../client/connect'; +import { connectToBrowserAcrossVersions } from '../utils/connect'; import type * as api from '../../..'; import type { SessionStatus } from '../../../../dashboard/src/sessionModel'; @@ -215,6 +215,7 @@ async function launchApp(appName: string) { }); const image = await fs.promises.readFile(path.join(__dirname, 'appIcon.png')); + // This is local Playwright, so I can access private methods. await (page as any)._setDockTile(image); await syncLocalStorageWithSettings(page, appName); return { context, page }; diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index b6fa67eb26f44..a20cb91420c79 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -15,7 +15,7 @@ */ import { eventsHelper } from '../../server/utils/eventsHelper'; -import { connectToBrowserAcrossVersions } from '../../client/connect'; +import { connectToBrowserAcrossVersions } from '../utils/connect'; import type * as api from '../../..'; import type { Transport } from '../../server/utils/httpServer'; diff --git a/packages/playwright-core/src/tools/mcp/DEPS.list b/packages/playwright-core/src/tools/mcp/DEPS.list index bf1c164100ed8..398a7e9fd92ee 100644 --- a/packages/playwright-core/src/tools/mcp/DEPS.list +++ b/packages/playwright-core/src/tools/mcp/DEPS.list @@ -1,7 +1,6 @@ [*] ../../.. ../../ -../../client/connect.ts ../utils/mcp/ ../backend/ ../../utils/ @@ -12,3 +11,7 @@ ../../server/registry/ ../../server/utils/ ../../serverRegistry.ts + +[browserFactory.ts] +../../client/connect.ts +../utils/connect.ts diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index d4ed8634d3741..26a16054941c1 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -24,19 +24,28 @@ import { registryDirectory } from '../../server/registry/index'; import { testDebug } from './log'; import { outputDir } from '../backend/context'; import { createExtensionBrowser } from './extensionContextFactory'; -import { connectToBrowser, connectToBrowserAcrossVersions } from '../../client/connect'; +import { connectToBrowserAcrossVersions } from '../utils/connect'; import { serverRegistry } from '../../serverRegistry'; +// eslint-disable-next-line no-restricted-imports +import { connectToBrowser } from '../../client/connect'; import type { FullConfig } from './config'; -import type { ConnectOptions } from '../../client/types'; import type { ClientInfo } from '../utils/mcp/server'; +// eslint-disable-next-line no-restricted-imports import type { Playwright } from '../../client/playwright'; +// eslint-disable-next-line no-restricted-imports +import type { Browser } from '../../client/browser'; -export async function createBrowser(config: FullConfig, clientInfo: ClientInfo): Promise { +type ClientInfoEx = ClientInfo & { + sessionName?: string; + workspaceDir?: string; +}; + +export async function createBrowser(config: FullConfig, clientInfo: ClientInfoEx): Promise { if (config.browser.remoteEndpoint) return await createRemoteBrowser(config); if (config.browser.cdpEndpoint) - return await createCDPBrowser(config); + return await createCDPBrowser(config, clientInfo); if (config.browser.isolated) return await createIsolatedBrowser(config, clientInfo); if (config.extension) @@ -49,12 +58,12 @@ export interface BrowserContextFactory { createContext(clientInfo: ClientInfo): Promise; } -async function createIsolatedBrowser(config: FullConfig, clientInfo: ClientInfo): Promise { +async function createIsolatedBrowser(config: FullConfig, clientInfo: ClientInfoEx): Promise { testDebug('create browser (isolated)'); await injectCdpPort(config.browser); const browserType = playwright[config.browser.browserName]; const tracesDir = await computeTracesDir(config, clientInfo); - return await browserType.launch({ + const browser = await browserType.launch({ tracesDir, ...config.browser.launchOptions, handleSIGINT: false, @@ -64,14 +73,18 @@ async function createIsolatedBrowser(config: FullConfig, clientInfo: ClientInfo) throwBrowserIsNotInstalledError(config); throw error; }); + await startServer(browser, clientInfo); + return browser; } -async function createCDPBrowser(config: FullConfig): Promise { +async function createCDPBrowser(config: FullConfig, clientInfo: ClientInfoEx): Promise { testDebug('create browser (cdp)'); - return playwright.chromium.connectOverCDP(config.browser.cdpEndpoint!, { + const browser = await playwright.chromium.connectOverCDP(config.browser.cdpEndpoint!, { headers: config.browser.cdpHeaders, timeout: config.browser.cdpTimeout }); + await startServer(browser, clientInfo); + return browser; } async function createRemoteBrowser(config: FullConfig): Promise { @@ -81,14 +94,14 @@ async function createRemoteBrowser(config: FullConfig): Promise { +async function createPersistentBrowser(config: FullConfig, clientInfo: ClientInfoEx): Promise { testDebug('create browser (persistent)'); await injectCdpPort(config.browser); const userDataDir = config.browser.userDataDir ?? await createUserDataDir(config, clientInfo); @@ -107,11 +120,12 @@ async function createPersistentBrowser(config: FullConfig, clientInfo: ClientInf ignoreDefaultArgs: [ '--disable-extensions', ], - assistantMode: true, - } as any; + }; try { const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions); - return browserContext.browser()!; + const browser = browserContext.browser()!; + await startServer(browser, clientInfo); + return browser; } catch (error: any) { if (error.message.includes('Executable doesn\'t exist')) throwBrowserIsNotInstalledError(config); @@ -201,3 +215,8 @@ function throwBrowserIsNotInstalledError(config: FullConfig): never { else throw new Error(`Browser "${channel}" is not installed. Run \`npx @playwright/mcp install-browser ${channel}\` to install`); } + +async function startServer(browser: playwright.Browser, clientInfo: ClientInfoEx) { + if (clientInfo.sessionName) + await (browser as Browser)._register(clientInfo.sessionName, { workspaceDir: clientInfo.workspaceDir }); +} diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 3f87148bd5d76..e4599ccd013a8 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -345,6 +345,7 @@ export function mergeConfig(base: FullConfig, overrides: Config): FullConfig { launchOptions: { ...pickDefined(base.browser?.launchOptions), ...pickDefined(overrides.browser?.launchOptions), + // Assistant mode is not a part of the public API. ...{ assistantMode: true }, }, contextOptions: { diff --git a/packages/playwright-core/src/tools/utils/DEPS.list b/packages/playwright-core/src/tools/utils/DEPS.list index 844f0c46677d5..fad12b908c614 100644 --- a/packages/playwright-core/src/tools/utils/DEPS.list +++ b/packages/playwright-core/src/tools/utils/DEPS.list @@ -1,2 +1,5 @@ [socketConnection.ts] "strict" + +[connect.ts] +"strict" diff --git a/packages/playwright-core/src/tools/utils/connect.ts b/packages/playwright-core/src/tools/utils/connect.ts new file mode 100644 index 0000000000000..647cd3c8f914d --- /dev/null +++ b/packages/playwright-core/src/tools/utils/connect.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type * as playwright from '../../..'; +import type { BrowserDescriptor } from '../../serverRegistry'; + +export async function connectToBrowserAcrossVersions(descriptor: BrowserDescriptor): Promise { + const pw = require(descriptor.playwrightLib); + const browserType = pw[descriptor.browser.browserName] as playwright.BrowserType; + return await browserType.connect(descriptor.pipeName!); +} diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 21d6444d37a0f..0f3a236cf35c9 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -14190,13 +14190,6 @@ export interface Locator { hasText?: string|RegExp; }): Locator; - /** - * Returns a new locator that uses best practices for referencing the matched element, prioritizing test ids, aria - * roles, and other user-facing attributes over CSS selectors. This is useful for converting implementation-detail - * selectors into more resilient, human-readable locators. - */ - normalize(): Promise; - /** * Returns locator to the n-th matching element. It's zero based, `nth(0)` selects the first element. * @@ -14762,6 +14755,12 @@ export interface Locator { timeout?: number; }): Promise; + /** + * Returns a code string for a locator that uses best practices for referencing the matched element, prioritizing test + * ids, aria roles, and other user-facing attributes over CSS selectors. + */ + toCode(): Promise; + /** * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and `keyup` event for each character in the * text. diff --git a/packages/playwright/src/mcp/test/browserBackend.ts b/packages/playwright/src/mcp/test/browserBackend.ts index 3a3f5787b7eb2..bbe38b2ae8a16 100644 --- a/packages/playwright/src/mcp/test/browserBackend.ts +++ b/packages/playwright/src/mcp/test/browserBackend.ts @@ -20,9 +20,8 @@ import * as tools from 'playwright-core/lib/tools/exports'; import { stripAnsiEscapes } from '../../util'; import type * as playwright from '../../../index'; -import type { Page } from '../../../../playwright-core/src/client/page'; -import type { Browser } from '../../../../playwright-core/src/client/browser'; import type { TestInfoImpl } from '../../worker/testInfo'; +import type { Browser } from '../../../../playwright-core/src/client/browser'; export type BrowserMCPRequest = { initialize?: { clientInfo: tools.ClientInfo }, @@ -99,7 +98,7 @@ async function generatePausedMessage(testInfo: TestInfoImpl, context: playwright lines.push( `- Page Snapshot:`, '```yaml', - (await (page as Page).snapshotForAI()).full, + (await page.snapshotForAI()).full, '```', ); } @@ -116,7 +115,7 @@ export async function runDaemonForBrowser(testInfo: TestInfoImpl, browser: playw return; const browserTitle = `test-worker-${createGuid().slice(0, 6)}`; - await (browser as Browser)._startServer(browserTitle, { workspaceDir: testInfo.project.testDir }); + await (browser as Browser)._register(browserTitle, { workspaceDir: testInfo.project.testDir }); const lines = ['']; if (testInfo.errors.length) { diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 0677bc9addab6..f384e99aedea4 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1178,22 +1178,15 @@ export type BrowserContextEvent = { export type BrowserCloseEvent = {}; export type BrowserStartServerParams = { title: string, - host?: string, - port?: number, - wsPath?: string, workspaceDir?: string, metadata?: any, }; export type BrowserStartServerOptions = { - host?: string, - port?: number, - wsPath?: string, workspaceDir?: string, metadata?: any, }; export type BrowserStartServerResult = { - wsEndpoint?: string, - pipeName?: string, + pipeName: string, }; export type BrowserStopServerParams = {}; export type BrowserStopServerOptions = {}; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 7c1a248d7019d..824adafc32a40 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1043,14 +1043,10 @@ Browser: title: Start server parameters: title: string - host: string? - port: int? - wsPath: string? workspaceDir: string? metadata: json? returns: - wsEndpoint: string? - pipeName: string? + pipeName: string stopServer: title: Stop server diff --git a/tests/library/browser-server.spec.ts b/tests/library/browser-server.spec.ts index bd605a0ce386a..cfec19ec756f4 100644 --- a/tests/library/browser-server.spec.ts +++ b/tests/library/browser-server.spec.ts @@ -26,7 +26,7 @@ it.beforeEach(({}, testInfo) => { }); it('should start and stop pipe server', async ({ browserType, browser }) => { - const serverInfo = await (browser as any)._startServer('default', {}); + const serverInfo = await (browser as any)._register('default', {}); expect(serverInfo).toEqual(expect.objectContaining({ pipeName: expect.stringMatching(/browser@.*\.sock/), })); @@ -37,27 +37,11 @@ it('should start and stop pipe server', async ({ browserType, browser }) => { expect(await page.locator('h1').textContent()).toBe('Hello via pipe'); await page.close(); await browser2.close(); - await (browser as any)._stopServer(); -}); - -it('should start and stop ws server', async ({ browserType, browser }) => { - const serverInfo = await (browser as any)._startServer('default', { wsPath: 'test' }); - expect(serverInfo).toEqual(expect.objectContaining({ - pipeName: expect.stringMatching(/browser@.*\.sock/), - wsEndpoint: expect.stringMatching(/^ws:\/\//), - })); - - const browser2 = await browserType.connect(serverInfo.wsEndpoint); - const page = await browser2.newPage(); - await page.goto('data:text/html,

Hello

'); - expect(await page.locator('h1').textContent()).toBe('Hello'); - await page.close(); - await browser2.close(); - await (browser as any)._stopServer(); + await (browser as any)._unregister(); }); it('should write descriptor on start and remove on stop', async ({ browser }) => { - const serverInfo = await (browser as any)._startServer('my-title', { wsPath: 'test' }); + const serverInfo = await (browser as any)._register('my-title', { wsPath: 'test' } as any); const registryDir = it.info().outputPath('registry'); const fileName = fs.readdirSync(registryDir)[0]; @@ -68,13 +52,12 @@ it('should write descriptor on start and remove on stop', async ({ browser }) => expect(descriptor.playwrightVersion).toBeTruthy(); expect(descriptor.playwrightLib).toBeTruthy(); expect(descriptor.browser.browserName).toBeTruthy(); - expect(descriptor.wsEndpoint).toBe(serverInfo.wsEndpoint); expect(descriptor.pipeName).toBe(serverInfo.pipeName); if (process.platform !== 'win32') expect(fs.existsSync(serverInfo.pipeName)).toBe(true); - await (browser as any)._stopServer(); + await (browser as any)._unregister(); expect(fs.existsSync(file)).toBe(false); if (process.platform !== 'win32') expect(fs.existsSync(serverInfo.pipeName)).toBe(false); diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index dab5e7b6c4751..cb596539714f2 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -288,7 +288,7 @@ test.describe('browser server', () => { test('list browser servers', async ({ cli, mcpBrowser }) => { const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); - await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); + await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); const { output } = await cli('list', '--all'); expect(output).toBe(`### Browser servers available for attach workspace1: @@ -301,7 +301,7 @@ workspace1: test('attach to browser server', async ({ cli, mcpBrowser }) => { const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); - await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); + await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); const page = await browser.newPage(); await page.setContent('My Page'); const { output: openOutput } = await cli('open', '--attach=foobar'); @@ -327,16 +327,36 @@ workspace1: test('fail to attach to browser server without contexts', async ({ cli, mcpBrowser }) => { const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); - await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); + await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); const { error } = await cli('open', '--attach=foobar'); expect(error).toContain('Error: unable to connect to a browser that does not have any contexts'); }); + test('attach via PLAYWRIGHT_CLI_SESSION env', async ({ cli, mcpBrowser }) => { + const browserName = mcpBrowser.replace('chrome', 'chromium'); + await using browser = await playwright[browserName].launch({ headless: true }); + const page = await browser.newPage(); + await page.setContent('Env Page

Hello from env

'); + await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); + const { output: openOutput, snapshot } = await cli('open', { env: { PLAYWRIGHT_CLI_SESSION: 'foobar' } }); + expect(openOutput).toContain('### Browser `foobar` opened with pid'); + expect(openOutput).toContain('Env Page'); + expect(snapshot).toContain('Hello from env'); + const { output: listOutput } = await cli('list', '--all'); + expect(listOutput).toBe(`### Browsers +/: +- foobar: + - status: open + - browser-type: ${mcpBrowser.replace('chrome', 'chromium')} + - user-data-dir: + - headed: true`); + }); + test('detach from browser server', async ({ cli, mcpBrowser }) => { const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); await browser.newPage(); - await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); + await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); const { output: openOutput } = await cli('open', '--attach=foobar'); expect(openOutput).toContain('### Browser `default` opened with pid'); await cli('close'); diff --git a/tests/mcp/run-code.spec.ts b/tests/mcp/run-code.spec.ts index b9ab561e9b51e..c68f14f7e86b8 100644 --- a/tests/mcp/run-code.spec.ts +++ b/tests/mcp/run-code.spec.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import fs from 'fs/promises'; import { test, expect } from './fixtures'; test('browser_run_code', async ({ client, server }) => { @@ -78,74 +77,6 @@ test('browser_run_code no-require', async ({ client, server }) => { }); }); -test('browser_run_code blocks fetch of file:// URLs by default', async ({ client, server }) => { - await client.callTool({ - name: 'browser_navigate', - arguments: { url: server.EMPTY_PAGE }, - }); - - expect(await client.callTool({ - name: 'browser_run_code', - arguments: { - code: `async (page) => { await page.request.get('file:///etc/passwd'); }`, - }, - })).toHaveResponse({ - error: expect.stringContaining('Error: apiRequestContext.get: Access to "file:" protocol is blocked. Attempted URL: "file:///etc/passwd"'), - isError: true, - }); -}); - -test('browser_run_code restricts setInputFiles to roots by default', async ({ startClient, server }, testInfo) => { - const rootDir = testInfo.outputPath('workspace'); - await fs.mkdir(rootDir, { recursive: true }); - - const { client } = await startClient({ - roots: [ - { - name: 'workspace', - uri: `file://${rootDir}`, - } - ], - }); - - server.setContent('/', ``, 'text/html'); - - await client.callTool({ - name: 'browser_navigate', - arguments: { url: server.PREFIX }, - }); - - // Create a file inside the root - const fileInsideRoot = testInfo.outputPath('workspace', 'inside.txt'); - await fs.writeFile(fileInsideRoot, 'Inside root'); - - expect(await client.callTool({ - name: 'browser_run_code', - arguments: { - code: `async (page) => { - await page.locator('input').setInputFiles('${fileInsideRoot.replace(/\\/g, '\\\\')}'); - return 'success'; - }`, - }, - })).toHaveResponse({ - result: '"success"', - }); - - // Create a file outside the root - const fileOutsideRoot = testInfo.outputPath('outside.txt'); - await fs.writeFile(fileOutsideRoot, 'Outside root'); - - expect(await client.callTool({ - name: 'browser_run_code', - arguments: { - code: `(page) => page.locator('input').setInputFiles('${fileOutsideRoot.replace(/\\/g, '\\\\')}')`, - }, - })).toHaveResponse({ - isError: true, - error: expect.stringMatching('File access denied: .* is outside allowed roots'), - }); -}); - test('browser_run_code return value', async ({ client, server }) => { server.setContent('/', ` diff --git a/tests/page/page-aria-snapshot-ai.spec.ts b/tests/page/page-aria-snapshot-ai.spec.ts index cc1e30592b769..6f924cdb6716c 100644 --- a/tests/page/page-aria-snapshot-ai.spec.ts +++ b/tests/page/page-aria-snapshot-ai.spec.ts @@ -92,20 +92,20 @@ it('should stitch all frame snapshots', async ({ page, server }) => { expect(href3).toBe(server.PREFIX + '/frames/frame.html'); { - const resolved = await page.locator('aria-ref=e1').normalize(); - expect(resolved.toString()).toBe(`locator('body')`); + const resolved = await page.locator('aria-ref=e1').toCode(); + expect(resolved).toBe(`locator('body')`); } { - const resolved = await page.locator('aria-ref=f4e2').normalize(); - expect(resolved.toString()).toBe(`locator('iframe[name="2frames"]').contentFrame().locator('iframe[name="dos"]').contentFrame().getByText('Hi, I\\'m frame')`); + const resolved = await page.locator('aria-ref=f4e2').toCode(); + expect(resolved).toBe(`locator('iframe[name="2frames"]').contentFrame().locator('iframe[name="dos"]').contentFrame().getByText('Hi, I\\'m frame')`); } { // Should tolerate .describe(). - const resolved = await page.locator('aria-ref=f3e2').describe('foo bar').normalize(); - expect(resolved.toString()).toBe(`locator('iframe[name=\"2frames\"]').contentFrame().locator('iframe[name=\"uno\"]').contentFrame().getByText('Hi, I\\'m frame')`); + const resolved = await page.locator('aria-ref=f3e2').describe('foo bar').toCode(); + expect(resolved).toBe(`locator('iframe[name=\"2frames\"]').contentFrame().locator('iframe[name=\"uno\"]').contentFrame().getByText('Hi, I\\'m frame')`); } { - const error = await page.locator('aria-ref=e1000').normalize().catch(e => e); + const error = await page.locator('aria-ref=e1000').toCode().catch(e => e); expect(error.message).toContain(`No element matching aria-ref=e1000`); } });