diff --git a/packages/playwright-core/src/tools/backend/runCode.ts b/packages/playwright-core/src/tools/backend/runCode.ts index 62905a7b0c9ed..cfe8d84b7c06f 100644 --- a/packages/playwright-core/src/tools/backend/runCode.ts +++ b/packages/playwright-core/src/tools/backend/runCode.ts @@ -22,6 +22,13 @@ import { ManualPromise } from '@isomorphic/manualPromise'; import { defineTabTool } from './tool'; +const exposedGlobals = [ + 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'setImmediate', 'clearImmediate', 'queueMicrotask', + 'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder', 'AbortController', 'AbortSignal', + 'fetch', 'Headers', 'Request', 'Response', 'FormData', 'Blob', + 'Buffer', 'crypto', 'atob', 'btoa', 'structuredClone', +]; + const codeSchema = z.object({ code: z.string().optional().describe(`A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: \`async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }\``), filename: z.string().optional().describe('Load code from the specified file. Relative file names are resolved against the workspace root. If both code and filename are provided, code will be ignored.'), @@ -49,6 +56,8 @@ const runCode = defineTabTool({ page: tab.page, __end__, }; + for (const name of exposedGlobals) + context[name] = globalThis[name as keyof typeof globalThis]; vm.createContext(context); // User-installed callbacks (e.g. page.route handlers) can throw // asynchronously while __fn__ awaits an operation that depends on them diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/references/running-code.md b/packages/playwright-core/src/tools/skills/playwright-cli/references/running-code.md index 98b541fb54cd6..5bf7e8cfb5211 100644 --- a/packages/playwright-core/src/tools/skills/playwright-cli/references/running-code.md +++ b/packages/playwright-core/src/tools/skills/playwright-cli/references/running-code.md @@ -21,6 +21,10 @@ playwright-cli run-code --filename=./my-script.js The code must be a single function expression, it is wrapped in `(...)` and evaluated. import/export/require syntax is not supported. +The code runs in an isolated context, not in a full Node.js environment. `require`, `process` and Node modules are not +available. Timers (`setTimeout`, `setInterval`), `fetch`, `URL`, `Buffer`, `crypto`, `AbortController`, `TextEncoder` +and `TextDecoder` are available. + ## Geolocation ```bash diff --git a/tests/mcp/cli-run-code.spec.ts b/tests/mcp/cli-run-code.spec.ts index f14eff4b0a1dc..7bb97ce92c9b5 100644 --- a/tests/mcp/cli-run-code.spec.ts +++ b/tests/mcp/cli-run-code.spec.ts @@ -58,3 +58,16 @@ test('run-code from file with template literals', async ({ cli, server }) => { const { output } = await cli('run-code', '--filename=template-script.js'); expect(output).toContain('result: templated'); }); + +test('run-code delayed route with setTimeout', async ({ cli, server }) => { + server.setContent('/', '
Hello
', 'text/html'); + await cli('open', server.PREFIX); + await cli('run-code', `async page => { + await page.route('**/api/slow', async route => { + await new Promise(r => setTimeout(r, 100)); + await route.fulfill({ body: 'loaded' }); + }); + }`); + const { output } = await cli('eval', 'async () => { const r = await fetch("/api/slow"); return r.text(); }'); + expect(output).toContain('loaded'); +}); diff --git a/tests/mcp/run-code.spec.ts b/tests/mcp/run-code.spec.ts index 257e70c86ff56..c32f69091e9fa 100644 --- a/tests/mcp/run-code.spec.ts +++ b/tests/mcp/run-code.spec.ts @@ -118,8 +118,7 @@ test('browser_run_code_unsafe route handler exception keeps server alive', async const code = `async (page) => { await page.unroute('**/*').catch(() => {}); await page.route('**/route-throws.json', async (route) => { - const path = new URL(route.request().url()).pathname; - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ path }) }); + throw new Error('route handler failed'); }); return await page.evaluate(async () => { const response = await fetch('/route-throws.json'); @@ -130,7 +129,7 @@ test('browser_run_code_unsafe route handler exception keeps server alive', async name: 'browser_run_code_unsafe', arguments: { code }, })).toHaveResponse({ - error: expect.stringContaining('ReferenceError: URL is not defined'), + error: expect.stringContaining('route handler failed'), isError: true, }); @@ -142,6 +141,69 @@ test('browser_run_code_unsafe route handler exception keeps server alive', async expect(followUp.isError).toBeFalsy(); }); +test('browser_run_code_unsafe exposes benign globals', async ({ client, server }) => { + server.setContent('/', '
Hello
', 'text/html'); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + const code = `async (page) => { + return { + setTimeout: typeof setTimeout, + URL: typeof URL, + fetch: typeof fetch, + Buffer: typeof Buffer, + crypto: typeof crypto, + AbortController: typeof AbortController, + TextEncoder: typeof TextEncoder, + require: typeof require, + process: typeof process, + }; + }`; + expect(await client.callTool({ + name: 'browser_run_code_unsafe', + arguments: { code }, + })).toHaveResponse({ + result: JSON.stringify({ + setTimeout: 'function', + URL: 'function', + fetch: 'function', + Buffer: 'function', + crypto: 'object', + AbortController: 'function', + TextEncoder: 'function', + require: 'undefined', + process: 'undefined', + }), + }); +}); + +test('browser_run_code_unsafe delayed route with setTimeout', async ({ client, server }) => { + server.setContent('/', '
Hello
', 'text/html'); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + const code = `async (page) => { + await page.route('**/api/slow', async route => { + await new Promise(r => setTimeout(r, 100)); + await route.fulfill({ body: JSON.stringify({ path: new URL(route.request().url()).pathname }) }); + }); + return await page.evaluate(async () => { + const response = await fetch('/api/slow'); + return response.json(); + }); + }`; + expect(await client.callTool({ + name: 'browser_run_code_unsafe', + arguments: { code }, + })).toHaveResponse({ + result: JSON.stringify({ path: '/api/slow' }), + }); +}); + test('browser_run_code_unsafe with filename', async ({ client, server }) => { server.setContent('/', `