Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions docs/src/api/class-locator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]>
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 6 additions & 7 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Locator>;

/**
* Returns locator to the n-th matching element. It's zero based, `nth(0)` selects the first element.
*
Expand Down Expand Up @@ -14762,6 +14755,12 @@ export interface Locator {
timeout?: number;
}): Promise<null|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.
*/
toCode(): Promise<string>;

/**
* Focuses the element, and then sends a `keydown`, `keypress`/`input`, and `keyup` event for each character in the
* text.
Expand Down
9 changes: 5 additions & 4 deletions packages/playwright-core/src/client/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -130,11 +130,12 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> 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<string, any>, wsPath?: string } = {}): Promise<{ pipeName: string }> {
const { pipeName } = await this._channel.startServer({ title, ...options });
return { pipeName };
}

async _stopServer(): Promise<void> {
async _unregister(): Promise<void> {
await this._channel.stopServer();
}

Expand Down
38 changes: 1 addition & 37 deletions packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
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;
Expand All @@ -97,7 +96,6 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
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)));
Expand Down Expand Up @@ -559,40 +557,6 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
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<NonNullable<channels.BrowserNewContextParams['storageState']>> {
Expand Down
10 changes: 0 additions & 10 deletions packages/playwright-core/src/client/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Browser> {
const deadline = params.timeout ? monotonicTime() + params.timeout : 0;
Expand Down Expand Up @@ -101,14 +99,6 @@ export async function connectToEndpoint(parentConnection: Connection, params: ch
return connection;
}

export async function connectToBrowserAcrossVersions(descriptor: BrowserDescriptor): Promise<playwright.Browser> {
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<HeadersArray>;
send(message: any): Promise<void>;
Expand Down
4 changes: 0 additions & 4 deletions packages/playwright-core/src/client/elementHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 0 additions & 2 deletions packages/playwright-core/src/client/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
readonly _tracing: Tracing;
private _closeReason: string | undefined;
_timeoutSettings: TimeoutSettings;
_checkUrlAllowed?: (url: string) => void;

static from(channel: channels.APIRequestContextChannel): APIRequestContext {
return (channel as any)._object;
Expand Down Expand Up @@ -178,7 +177,6 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
assert(options.maxRedirects === undefined || options.maxRedirects >= 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')
Expand Down
1 change: 0 additions & 1 deletion packages/playwright-core/src/client/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr

async goto(url: string, options: channels.FrameGotoOptions & TimeoutOptions = {}): Promise<network.Response | null> {
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);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,9 @@ export class Locator implements api.Locator {
return await this._frame._queryCount(this._selector, _options);
}

async normalize(): Promise<Locator> {
async toCode(): Promise<string> {
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<string | null> {
Expand Down
6 changes: 1 addition & 5 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}));
Expand Down
18 changes: 5 additions & 13 deletions packages/playwright-core/src/server/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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,
Expand All @@ -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() {
Expand Down
21 changes: 13 additions & 8 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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, '\'') };
Expand Down Expand Up @@ -343,7 +348,7 @@ function originOrHostGlob(originOrHost: string) {
export async function workspaceFile(options: ContextOptions, fileName: string, perCallWorkspaceDir?: string): Promise<string> {
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;
}

Expand All @@ -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}`);
}
3 changes: 3 additions & 0 deletions packages/playwright-core/src/tools/backend/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/backend/navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const navigate = defineTool({
url = 'https://' + url;
}

context.checkUrlAllowed(url);
await tab.navigate(url);

response.setIncludeSnapshot();
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,18 @@ export class Response {
async resolveClientFile(template: FilenameTemplate, title: string): Promise<ResolvedFile> {
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);
const printableLink = `- [${title}](${relativeName})`;
return { fileName, relativeName, printableLink };
}

async resolveClientFilename(filename: string): Promise<string> {
return await this._context.workspaceFile(filename, this._clientWorkspace);
}

addTextResult(text: string) {
this._results.push(text);
}
Expand Down
Loading
Loading