diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index 3396d37ddc0c8..e793291e12c4b 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -144,13 +144,11 @@ jobs: flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} - transport_linux: - name: "Transport" + driver_linux: + name: "Driver" environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} strategy: fail-fast: false - matrix: - mode: [driver, service] runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v6 @@ -158,12 +156,12 @@ jobs: with: browsers-to-install: chromium command: npm run ctest - bot-name: "${{ matrix.mode }}" + bot-name: "driver" flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: - PWTEST_MODE: ${{ matrix.mode }} + PWTEST_MODE: driver tracing_linux: name: Tracing ${{ matrix.browser }} ${{ matrix.channel }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67468d397d634..7e1c6d1b3cc0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,19 @@ # Contributing -## Choose an issue +## Choosing an Issue -Playwright **requires an issue** for every contribution, except for minor documentation updates. We strongly recommend -to pick an issue -labeled [open-to-a-pull-request](https://github.com/microsoft/playwright/issues?q=is%3Aissue%20state%3Aopen%20label%3Aopen-to-a-pull-request) -for your first contribution to the project. +To maintain project quality and focus, Playwright **requires a corresponding issue** for every contribution, with the exception of minor documentation fixes. -If you are passionate about a bug/feature, but cannot find an issue describing it, **file an issue first**. This will -facilitate the discussion, and you might get some early feedback from project maintainers before spending your time on -creating a pull request. +If you would like to address a bug or feature that isn't currently listed, **please file a new issue first**. This allows the community and maintainers to provide early feedback and facilitates a discussion before you invest time in developing a pull request. + +When submitting an issue, please state clearly if you intend to work on it. Once triaged and approved, the maintainers will determine the best path forward—whether the task should be handled by the **core team**, an **automated agent**, or a **community contributor**. If the issue is assigned to you, you may then proceed with your changes and submit a PR. + +### Submission Policy +To ensure the maintainability of the project, please note the following: + +* **Unsolicited PRs:** Pull requests submitted without a linked issue or prior approval will be closed. +* **Low-Quality AI Contributions:** PRs that do not meet our quality standards or lack human oversight (including low-quality agentic submissions) will be closed without explanation. +* **Approval Required:** Only proceed with a PR once the issue has been officially assigned to you or approved for community contribution. ## Make a change diff --git a/docs/src/api/class-formdata.md b/docs/src/api/class-formdata.md index 8582333b3ed1d..ad93869411125 100644 --- a/docs/src/api/class-formdata.md +++ b/docs/src/api/class-formdata.md @@ -1,6 +1,6 @@ # class: FormData * since: v1.18 -* langs: java, csharp +* langs: java, csharp, python The [FormData] is used create form data that is sent via [APIRequestContext]. @@ -14,6 +14,22 @@ FormData form = FormData.create() page.request().post("http://localhost/submit", RequestOptions.create().setForm(form)); ``` +```python async +form = FormData() +form.set("firstName", "John") +form.set("lastName", "Doe") +form.set("age", 30) +await page.request.post("http://localhost/submit", form=form) +``` + +```python sync +form = FormData() +form.set("firstName", "John") +form.set("lastName", "Doe") +form.set("age", 30) +page.request.post("http://localhost/submit", form=form) +``` + ## method: FormData.append * since: v1.44 - returns: <[FormData]> @@ -60,6 +76,36 @@ multipart.Append("attachment", new FilePayload() await Page.APIRequest.PostAsync("https://localhost/submit", new() { Multipart = multipart }); ``` +```python async +form = FormData() +# Only name and value are set. +form.append("firstName", "John") +# Name and value are set, filename and Content-Type are inferred from the file path. +form.append("attachment", Path("pic.jpg")) +# Name, value, filename and Content-Type are set. +form.append("attachment", { + "name": "table.csv", + "mimeType": "text/csv", + "buffer": Path("my-table.csv").read_bytes(), +}) +await page.request.post("http://localhost/submit", multipart=form) +``` + +```python sync +form = FormData() +# Only name and value are set. +form.append("firstName", "John") +# Name and value are set, filename and Content-Type are inferred from the file path. +form.append("attachment", Path("pic.jpg")) +# Name, value, filename and Content-Type are set. +form.append("attachment", { + "name": "table.csv", + "mimeType": "text/csv", + "buffer": Path("my-table.csv").read_bytes(), +}) +page.request.post("http://localhost/submit", multipart=form) +``` + ### param: FormData.append.name * since: v1.44 - `name` <[string]> @@ -129,6 +175,38 @@ multipart.Set("age", 30); await Page.APIRequest.PostAsync("https://localhost/submit", new() { Multipart = multipart }); ``` +```python async +form = FormData() +# Only name and value are set. +form.set("firstName", "John") +# Name and value are set, filename and Content-Type are inferred from the file path. +form.set("profilePicture1", Path("john.jpg")) +# Name, value, filename and Content-Type are set. +form.set("profilePicture2", { + "name": "john.jpg", + "mimeType": "image/jpeg", + "buffer": Path("john.jpg").read_bytes(), +}) +form.set("age", 30) +await page.request.post("http://localhost/submit", multipart=form) +``` + +```python sync +form = FormData() +# Only name and value are set. +form.set("firstName", "John") +# Name and value are set, filename and Content-Type are inferred from the file path. +form.set("profilePicture1", Path("john.jpg")) +# Name, value, filename and Content-Type are set. +form.set("profilePicture2", { + "name": "john.jpg", + "mimeType": "image/jpeg", + "buffer": Path("john.jpg").read_bytes(), +}) +form.set("age", 30) +page.request.post("http://localhost/submit", multipart=form) +``` + ### param: FormData.set.name * since: v1.18 - `name` <[string]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index b976e9ac65878..df6b543ead2e4 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -486,11 +486,11 @@ unless explicitly provided. ## python-fetch-option-form * langs: python -- `form` <[Object]<[string], [string]|[float]|[boolean]>> +- `form` <[Object]<[string], [string]|[float]|[boolean]>|[FormData]> Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` -unless explicitly provided. +unless explicitly provided. Use [FormData] to send multiple values for the same field. ## csharp-fetch-option-form * langs: csharp @@ -516,7 +516,7 @@ or as file-like object containing file name, mime-type and its content. ## python-fetch-option-multipart * langs: python -- `multipart` <[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>> +- `multipart` <[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>|[FormData]> - `name` <[string]> File name - `mimeType` <[string]> File type - `buffer` <[Buffer]> File content @@ -524,6 +524,7 @@ or as file-like object containing file name, mime-type and its content. Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. +Use [FormData] to send multiple files in the same field. ## csharp-fetch-option-multipart * langs: csharp diff --git a/docs/src/test-reporters-js.md b/docs/src/test-reporters-js.md index b5c50d866063a..57d34a9bb4c45 100644 --- a/docs/src/test-reporters-js.md +++ b/docs/src/test-reporters-js.md @@ -242,6 +242,12 @@ Or if there is a custom folder name: npx playwright show-report my-report ``` +You can also pass a `.zip` archive — for example one downloaded from a CI artifact. The archive must contain `index.html` at its top level. Playwright will extract it to a temporary directory and serve the report: + +```bash +npx playwright show-report playwright-report.zip +``` + HTML report supports the following configuration options and environment variables: | Environment Variable Name | Reporter Config Option| Description | Default diff --git a/packages/dashboard/src/dashboardChannel.ts b/packages/dashboard/src/dashboardChannel.ts index fb484cbfcb168..2fe3b8799ec91 100644 --- a/packages/dashboard/src/dashboardChannel.ts +++ b/packages/dashboard/src/dashboardChannel.ts @@ -47,9 +47,7 @@ export interface DashboardChannel { closeTab(params: { browser: string; context: string; page: string }): Promise; newTab(params: { browser: string; context: string }): Promise; closeSession(params: { browser: string }): Promise; - deleteSessionData(params: { browser: string }): Promise; setVisible(params: { visible: boolean }): Promise; - reveal(params: { path: string }): Promise; navigate(params: { url: string }): Promise; back(): Promise; diff --git a/packages/dashboard/src/dashboardModel.ts b/packages/dashboard/src/dashboardModel.ts index 0817f3725dff1..3896ab71d1da9 100644 --- a/packages/dashboard/src/dashboardModel.ts +++ b/packages/dashboard/src/dashboardModel.ts @@ -92,10 +92,6 @@ export class DashboardModel { void this._client.closeSession({ browser: descriptor.browser.guid }); } - deleteSessionData(descriptor: BrowserDescriptor) { - void this._client.deleteSessionData({ browser: descriptor.browser.guid }); - } - setVisible(visible: boolean) { void this._client.setVisible({ visible }); } diff --git a/packages/isomorphic/urlMatch.ts b/packages/isomorphic/urlMatch.ts index cdd7f0aab0a61..c6851d996187e 100644 --- a/packages/isomorphic/urlMatch.ts +++ b/packages/isomorphic/urlMatch.ts @@ -16,6 +16,14 @@ import { isString } from './stringUtils'; +export function isHttpUrl(url: string): boolean { + try { + return ['http:', 'https:'].includes(new URL(url).protocol); + } catch { + return false; + } +} + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping const escapedChars = new Set(['$', '^', '+', '.', '*', '(', ')', '|', '\\', '?', '{', '}', '[', ']']); diff --git a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts index f2a538fb5557f..1b2d7a15871a4 100644 --- a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts @@ -27,11 +27,15 @@ import type { Progress } from '@protocol/progress'; export class AndroidDispatcher extends Dispatcher implements channels.AndroidChannel { _type_Android = true; - constructor(scope: RootDispatcher, android: Android) { + private readonly _denyLaunch: boolean; + constructor(scope: RootDispatcher, android: Android, denyLaunch: boolean) { super(scope, android, 'Android', {}); + this._denyLaunch = denyLaunch; } async devices(params: channels.AndroidDevicesParams, progress: Progress): Promise { + if (this._denyLaunch) + throw new Error(`Connecting to Android devices is not allowed.`); const devices = await this._object.devices(progress, params); return { devices: devices.map(d => AndroidDeviceDispatcher.from(this, d)) diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index 09b7f0ad904dd..f11677855fe68 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -46,13 +46,14 @@ export type PlaywrightDispatcherOptions = { export class PlaywrightDispatcher extends Dispatcher implements channels.PlaywrightChannel { _type_Playwright; private _browserDispatcher: BrowserDispatcher | undefined; + private _denyLaunch: boolean; constructor(scope: RootDispatcher, playwright: Playwright, options: PlaywrightDispatcherOptions = {}) { const denyLaunch = options.denyLaunch ?? false; const chromium = new BrowserTypeDispatcher(scope, playwright.chromium, denyLaunch); const firefox = new BrowserTypeDispatcher(scope, playwright.firefox, denyLaunch); const webkit = new BrowserTypeDispatcher(scope, playwright.webkit, denyLaunch); - const android = new AndroidDispatcher(scope, playwright.android); + const android = new AndroidDispatcher(scope, playwright.android, denyLaunch); const initializer: channels.PlaywrightInitializer = { chromium, firefox, @@ -78,9 +79,12 @@ export class PlaywrightDispatcher extends Dispatcher { + if (this._denyLaunch) + throw new Error(`Creating new API request contexts is not allowed.`); const request = new GlobalAPIRequestContext(this._object, params); return { request: APIRequestContextDispatcher.from(this.parentScope(), request) }; } diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index 73596ae86a7a0..b769738fded92 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -50,7 +50,7 @@ export class BrowserBackend implements ServerBackend { await this._context?.dispose().catch(e => debug('pw:tools:error')(e)); } - async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record } = {}): Promise { + async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record } = {}, signal?: AbortSignal): Promise { const json = !!rawArguments._meta?.json; const formatError = (message: string): mcpServer.CallToolResult => ({ content: [{ type: 'text' as const, text: json ? JSON.stringify({ isError: true, error: message }, null, 2) : `### Error\n${message}` }], @@ -68,7 +68,7 @@ export class BrowserBackend implements ServerBackend { context.setRunningTool(name); let responseObject: mcpServer.CallToolResult; try { - await tool.handle(context, parsedArguments, response); + await tool.handle(context, parsedArguments, response, signal); for (const reason of context.drainPendingUnhandledRejections()) response.addError(formatRejectionReason(reason)); responseObject = await response.serialize(); diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index e0ba8dc3d439c..852ff4c2eb838 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -15,13 +15,14 @@ */ import fs from 'fs'; +import os from 'os'; import path from 'path'; import debug from 'debug'; import { escapeWithQuotes } from '@isomorphic/stringUtils'; import { disposeAll } from '@isomorphic/disposable'; import { eventsHelper } from '@utils/eventsHelper'; -import { isPathInside } from '@utils/fileUtils'; +import { isPathInside, isSystemDirectory, isWritable } from '@utils/fileUtils'; import { playwright } from '../../inprocess'; import { Tab } from './tab'; @@ -387,7 +388,10 @@ export async function workspaceFile(options: ContextOptions, fileName: string, p export function outputDir(options: ContextOptions): string { if (options.config.outputDir) return path.resolve(options.config.outputDir); - return path.resolve(options.cwd, options.config.skillMode ? '.playwright-cli' : '.playwright-mcp'); + const baseName = options.config.skillMode ? '.playwright-cli' : '.playwright-mcp'; + if (isSystemDirectory(options.cwd) || !isWritable(options.cwd)) + return path.join(os.tmpdir(), baseName); + return path.join(options.cwd, baseName); } export async function outputFile(options: ContextOptions, fileName: string, flags: { origin: 'code' | 'llm' }): Promise { diff --git a/packages/playwright-core/src/tools/backend/devtools.ts b/packages/playwright-core/src/tools/backend/devtools.ts index e60eb634a3e82..4fc8cf7794e41 100644 --- a/packages/playwright-core/src/tools/backend/devtools.ts +++ b/packages/playwright-core/src/tools/backend/devtools.ts @@ -124,7 +124,7 @@ const annotate = defineTabTool({ type: 'readOnly', }, - handle: async (tab, params, response) => { + handle: async (tab, params, response, signal) => { // eslint-disable-next-line no-restricted-syntax -- _guid is the cross-process page identifier shared with the dashboard daemon. const pageId = (tab.page as any)._guid as string; const daemonScript = libPath('entry', 'dashboardApp.js'); @@ -138,9 +138,16 @@ const annotate = defineTabTool({ const client = spawn(process.execPath, [...daemonArgs, '--annotate', '--json'], { stdio: ['pipe', 'pipe', 'inherit'], }); + const onAbort = () => client.kill(); + signal?.addEventListener('abort', onAbort); const stdoutChunks: Buffer[] = []; client.stdout!.on('data', chunk => stdoutChunks.push(chunk)); const exitCode = await new Promise(resolve => client.on('exit', code => resolve(code))); + signal?.removeEventListener('abort', onAbort); + if (signal?.aborted) { + response.addTextResult('Annotation cancelled.'); + return; + } if (exitCode !== 0) { response.addError(`Annotation client exited with code ${exitCode}`); return; diff --git a/packages/playwright-core/src/tools/backend/tool.ts b/packages/playwright-core/src/tools/backend/tool.ts index 37de63c1c8383..0b091c4d63c21 100644 --- a/packages/playwright-core/src/tools/backend/tool.ts +++ b/packages/playwright-core/src/tools/backend/tool.ts @@ -52,7 +52,7 @@ export type Tool = { capability: ToolCapability; skillOnly?: boolean; schema: ToolSchema; - handle: (context: Context, params: z.output, response: Response) => Promise; + handle: (context: Context, params: z.output, response: Response, signal?: AbortSignal) => Promise; }; export function defineTool(tool: Tool): Tool { @@ -64,13 +64,13 @@ export type TabTool = { skillOnly?: boolean; schema: ToolSchema; clearsModalState?: ModalState['type']; - handle: (tab: Tab, params: z.output, response: Response) => Promise; + handle: (tab: Tab, params: z.output, response: Response, signal?: AbortSignal) => Promise; }; export function defineTabTool(tool: TabTool): Tool { return { ...tool, - handle: async (context, params, response) => { + handle: async (context, params, response, signal) => { const tab = await context.ensureTab(); const modalStates = tab.modalStates().map(state => state.type); if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) @@ -78,7 +78,7 @@ export function defineTabTool(tool: TabTool): Too else if (!tool.clearsModalState && modalStates.length) response.addError(`Error: Tool "${tool.schema.name}" does not handle the modal state.`); else - return tool.handle(tab, params, response); + return tool.handle(tab, params, response, signal); }, }; } diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 0a6596ad8acc8..b3dff96875493 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -47,7 +47,7 @@ const open = declareCommand({ config: z.string().optional().describe('Path to the configuration file, defaults to .playwright/cli.config.json'), headed: z.boolean().optional().describe('Run browser in headed mode'), persistent: z.boolean().optional().describe('Use persistent browser profile'), - profile: z.string().optional().describe('Use persistent browser profile, store profile in specified directory.'), + profile: z.string().optional().describe('Path to a persistent user data directory.'), }), toolName: '', toolParams: () => ({}), diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index 20dfa9fe61269..f5b699d0dd286 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -28,9 +28,12 @@ import { findChromiumChannelBestEffort, registryDirectory } from '../../server/r import { minimist } from '../cli-client/minimist'; import { saveOutputFile } from '../trace/traceUtils'; import { DashboardConnection } from './dashboardController'; +import { RegistrySessionProvider } from './registrySessionProvider'; +import { IdentitySessionProvider } from './identitySessionProvider'; import type * as api from '../../..'; import type { AnnotationData } from '@dashboard/dashboardChannel'; +import type { SessionProvider } from './sessionProvider'; // HMR: build-time flag — `true` in watch builds, `false` in release. esbuild // replaces the identifier via `define`, so the static branch pays zero runtime @@ -42,9 +45,10 @@ type DashboardServer = { reveal: (options: DashboardOptions) => void; triggerAnnotate: () => void; registerAnnotateWaiter: (socket: net.Socket) => void; + close: () => Promise; }; -async function startDashboardServer(options: DashboardOptions): Promise { +async function startDashboardServer(provider: SessionProvider, options: DashboardOptions): Promise { const httpServer = new HttpServer(); const dashboardDir = libPath('vite', 'dashboard'); @@ -67,7 +71,7 @@ async function startDashboardServer(options: DashboardOptions): Promise { let connection: DashboardConnection; // eslint-disable-next-line prefer-const - connection = new DashboardConnection(() => connections.delete(connection), () => { + connection = new DashboardConnection(provider, () => connections.delete(connection), () => { if (currentReveal.pageId) connection.revealPage(currentReveal.pageId); else if (currentReveal.sessionName) @@ -131,7 +135,8 @@ async function startDashboardServer(options: DashboardOptions): Promise httpServer.stop(); + return { url: httpServer.urlPrefix('human-readable'), reveal, triggerAnnotate, registerAnnotateWaiter, close }; } function attachDashboardStaticServer(httpServer: HttpServer, dashboardDir: string) { @@ -157,13 +162,13 @@ async function attachDashboardDevServer(httpServer: HttpServer) { // HMR end async function innerOpenDashboardApp(options: DashboardOptions): Promise<{ page: api.Page; server: DashboardServer }> { - const server = await startDashboardServer(options); - const { page } = await launchApp('dashboard'); + const server = await startDashboardServer(new RegistrySessionProvider(), options); + const { page } = await launchApp('dashboard', { onClose: () => gracefullyProcessExitDoNotHang(0) }); await page.goto(server.url); return { page, server }; } -async function launchApp(appName: string) { +async function launchApp(appName: string, options?: { onClose?: () => void }) { const channel = findChromiumChannelBestEffort('javascript'); const context = await playwright.chromium.launchPersistentContext('', { ignoreDefaultArgs: ['--enable-automation'], @@ -192,9 +197,7 @@ async function launchApp(appName: string) { }); } - page.on('close', () => { - gracefullyProcessExitDoNotHang(0); - }); + page.on('close', () => options?.onClose?.()); const image = await fs.promises.readFile(libPath('tools', 'dashboard', 'appIcon.png')); // This is local Playwright, so I can access private methods. @@ -299,7 +302,7 @@ export async function openDashboardApp() { console.error('Unhandled promise rejection:', error); }); if (options.port !== undefined) { - const { url } = await startDashboardServer(options); + const { url } = await startDashboardServer(new RegistrySessionProvider(), options); // eslint-disable-next-line no-console console.log(`Listening on ${url}`); selfDestructOnParentGone(); @@ -352,6 +355,22 @@ export async function openDashboardApp() { await statePromise; } +export async function openDashboardForContext(context: api.BrowserContext): Promise { + const server = await startDashboardServer(new IdentitySessionProvider(context), {}); + + let closed = false; + const close = async () => { + if (closed) + return; + closed = true; + await server.close(); + }; + + const { page } = await launchApp('dashboard', { onClose: () => { void close(); } }); + context.on('close', () => { void close(); }); + await page.goto(server.url); +} + async function runKillClient(): Promise { const socketPath = dashboardSocketPath(); await new Promise(resolve => { diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index 8b6305a16666e..c52868d4b9398 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -21,98 +21,25 @@ import crypto from 'crypto'; import { execFile } from 'child_process'; import { Disposable } from '@isomorphic/disposable'; import { eventsHelper } from '@utils/eventsHelper'; -import { connectToBrowserAcrossVersions } from '../utils/connect'; -import { serverRegistry } from '../../serverRegistry'; import { createClientInfo } from '../cli-client/registry'; +import { SessionProviderEvent } from './sessionProvider'; + import type * as api from '../../..'; import type { Transport } from '@utils/httpServer'; import type { AnnotationData, Tab } from '@dashboard/dashboardChannel'; import type { BrowserDescriptor } from '../../serverRegistry'; - -type BrowserTrackerCallbacks = { - onTabsChanged: () => void; - onContextClosed: (context: api.BrowserContext) => void; -}; - -class BrowserTracker { - readonly descriptor: BrowserDescriptor; - readonly browser: api.Browser; - private _callbacks: BrowserTrackerCallbacks; - private _contextListeners = new Map(); - private _browserListeners: Disposable[] = []; - - static async create(descriptor: BrowserDescriptor, callbacks: BrowserTrackerCallbacks): Promise { - try { - const browser = await connectToBrowserAcrossVersions(descriptor); - const slot = new BrowserTracker(descriptor, browser, callbacks); - for (const context of browser.contexts()) - slot._wireContext(context); - slot._browserListeners.push(eventsHelper.addEventListener(browser, 'context', (context: api.BrowserContext) => { - slot._wireContext(context); - })); - return slot; - } catch { - return undefined; - } - } - - private constructor(descriptor: BrowserDescriptor, browser: api.Browser, callbacks: BrowserTrackerCallbacks) { - this.descriptor = descriptor; - this.browser = browser; - this._callbacks = callbacks; - } - - contexts(): api.BrowserContext[] { - return this.browser.contexts(); - } - - dispose() { - this._browserListeners.forEach(d => d.dispose()); - this._browserListeners = []; - for (const listeners of this._contextListeners.values()) - listeners.forEach(d => d.dispose()); - this._contextListeners.clear(); - } - - private _wireContext(context: api.BrowserContext) { - if (this._contextListeners.has(context)) - return; - const onTabsChanged = () => this._callbacks.onTabsChanged(); - const listeners: Disposable[] = [ - eventsHelper.addEventListener(context, 'page', onTabsChanged), - eventsHelper.addEventListener(context, 'pageload', onTabsChanged), - eventsHelper.addEventListener(context, 'pageclose', onTabsChanged), - eventsHelper.addEventListener(context, 'framenavigated', (frame: api.Frame) => { - if (frame === frame.page().mainFrame()) - this._callbacks.onTabsChanged(); - }), - eventsHelper.addEventListener(context, 'close', () => { - const ls = this._contextListeners.get(context); - if (ls) { - ls.forEach(d => d.dispose()); - this._contextListeners.delete(context); - } - this._callbacks.onContextClosed(context); - this._callbacks.onTabsChanged(); - }), - ]; - this._contextListeners.set(context, listeners); - this._callbacks.onTabsChanged(); - } -} +import type { SessionProvider } from './sessionProvider'; export class DashboardConnection implements Transport { sendEvent?: (method: string, params: any) => void; close?: () => void; - private _browsers = new Map(); + private _provider: SessionProvider; private _attachedPage: AttachedPage | undefined; private _onclose: () => void; private _onconnected?: () => void; private _onAnnotationSubmit?: (base64Png: string | undefined, ariaSnapshot: string, annotations: AnnotationData[]) => void; - private _serverRegistryDispose?: () => void; - private _pushSessionsScheduled = false; private _pushTabsScheduled = false; private _visible = true; private _pendingReveal: { sessionName?: string; workspaceDir?: string; pageId?: string } | undefined; @@ -121,7 +48,8 @@ export class DashboardConnection implements Transport { _recordingDir: string; _streams = new Map(); - constructor(onclose: () => void, onconnected?: () => void, onAnnotationSubmit?: (base64Png: string | undefined, ariaSnapshot: string, annotations: AnnotationData[]) => void) { + constructor(provider: SessionProvider, onclose: () => void, onconnected?: () => void, onAnnotationSubmit?: (base64Png: string | undefined, ariaSnapshot: string, annotations: AnnotationData[]) => void) { + this._provider = provider; this._onclose = onclose; this._onconnected = onconnected; this._onAnnotationSubmit = onAnnotationSubmit; @@ -129,20 +57,24 @@ export class DashboardConnection implements Transport { } onconnect() { - this._serverRegistryDispose = serverRegistry.watch(); - serverRegistry.on('added', this._pushSessions); - serverRegistry.on('removed', this._pushSessions); - serverRegistry.on('changed', this._pushSessions); - this._pushSessions(); + this._provider.on(SessionProviderEvent.SessionsChanged, () => { + this._pushSessions(); + void this._tryRevealPending(); + }); + this._provider.on(SessionProviderEvent.TabsChanged, () => this._pushTabs()); + this._provider.on(SessionProviderEvent.ContextClosed, context => { + if (this._attachedPage?.page.context() === context) { + this._attachedPage.dispose(); + this._attachedPage = undefined; + } + }); + this._provider.on(SessionProviderEvent.AttachRequested, page => { void this._switchAttachedTo(page); }); + this._provider.start(); this._onconnected?.(); } onclose() { - serverRegistry.off('added', this._pushSessions); - serverRegistry.off('removed', this._pushSessions); - serverRegistry.off('changed', this._pushSessions); - this._serverRegistryDispose?.(); - this._serverRegistryDispose = undefined; + this._provider.dispose(); this._attachedPage?.dispose(); this._attachedPage = undefined; for (const stream of this._streams.values()) { @@ -152,9 +84,6 @@ export class DashboardConnection implements Transport { .catch(() => {}); } this._streams.clear(); - for (const tracker of this._browsers.values()) - tracker.dispose(); - this._browsers.clear(); this._onclose(); } @@ -173,14 +102,14 @@ export class DashboardConnection implements Transport { } async selectTab(params: { browser: string; context: string; page: string }) { - const page = this._findPage(params); + const page = this._provider.findPage(params); if (page) await this._switchAttachedTo(page); this._pushTabs(); } async newTab(params: { browser: string; context: string }) { - const context = this._findContext(params); + const context = this._provider.findContext(params); if (!context) return; const page = await context.newPage(); @@ -189,23 +118,12 @@ export class DashboardConnection implements Transport { } async closeTab(params: { browser: string; context: string; page: string }) { - const page = this._findPage(params); + const page = this._provider.findPage(params); await page?.close({ reason: 'Closed in Dashboard' }); } async closeSession(params: { browser: string }) { - const descriptor = serverRegistry.readDescriptor(params.browser); - const browser = await connectToBrowserAcrossVersions(descriptor); - try { - await Promise.all(browser.contexts().map(context => context.close())); - await browser.close(); - } catch { - // best-effort - } - } - - async deleteSessionData(params: { browser: string }) { - await serverRegistry.deleteUserData(params.browser); + await this._provider.closeSession(params.browser); } async setVisible(params: { visible: boolean }) { @@ -229,14 +147,14 @@ export class DashboardConnection implements Transport { const pending = this._pendingReveal; if (!pending) return; - const allPages = [...this._browsers.values()].flatMap(s => s.browser.contexts().flatMap(c => c.pages().map(p => ({ slot: s, page: p })))); + const allPages = this._provider.contextEntries().flatMap(e => e.context.pages().map(page => ({ entry: e, page }))); let page: api.Page | undefined; if (pending.pageId !== undefined) { - page = allPages.find(({ page }) => pageId(page) === pending.pageId)?.page; + page = allPages.find(({ page: p }) => pageId(p) === pending.pageId)?.page; } else if (pending.sessionName !== undefined) { - page = allPages.find(({ slot }) => - slot.descriptor.title === pending.sessionName - && (pending.workspaceDir === undefined || slot.descriptor.workspaceDir === pending.workspaceDir))?.page; + page = allPages.find(({ entry }) => + entry.descriptor.title === pending.sessionName + && (pending.workspaceDir === undefined || entry.descriptor.workspaceDir === pending.workspaceDir))?.page; } if (!page) return; @@ -308,6 +226,14 @@ export class DashboardConnection implements Transport { this.sendEvent?.('cancelAnnotate', {}); } + artifactsDirFor(context: api.BrowserContext): string { + for (const entry of this._provider.contextEntries()) { + if (entry.context === context) + return entry.descriptor.browser.launchOptions.artifactsDir ?? this._recordingDir; + } + return this._recordingDir; + } + _pushTabs() { if (this._pushTabsScheduled) return; @@ -323,22 +249,31 @@ export class DashboardConnection implements Transport { }); } + private _pushSessions() { + void (async () => { + try { + const sessions = await this._provider.sessions(); + this.emitSessions(sessions); + } catch { + // best-effort + } + })(); + } + private async _aggregateTabs(): Promise { const attachedPage = this._attachedPage?.page; const tasks: Promise[] = []; - for (const { browser } of this._browsers.values()) { - for (const context of browser.contexts()) { - for (const page of context.pages()) { - tasks.push((async () => ({ - browser: browserId(browser), - context: contextId(context), - page: pageId(page), - title: await page.title().catch(() => ''), - url: page.url(), - selected: page === attachedPage, - faviconUrl: await faviconUrl(page), - }))()); - } + for (const { browser, context } of this._provider.contextEntries()) { + for (const page of context.pages()) { + tasks.push((async () => ({ + browser: browserId(browser), + context: contextId(context), + page: pageId(page), + title: await page.title().catch(() => ''), + url: page.url(), + selected: page === attachedPage, + faviconUrl: await faviconUrl(page), + }))()); } } return await Promise.all(tasks); @@ -348,13 +283,7 @@ export class DashboardConnection implements Transport { if (this._attachedPage?.page === page) return; this._attachedPage?.dispose(); - const browser = page.context().browser(); - const slot = browser ? [...this._browsers.values()].find(s => s.browser === browser) : undefined; - if (!slot) { - this._attachedPage = undefined; - return; - } - const attached = new AttachedPage(this, slot, page); + const attached = new AttachedPage(this, page); this._attachedPage = attached; try { await attached.init(); @@ -378,101 +307,22 @@ export class DashboardConnection implements Transport { void this._switchAttachedTo(next); this._pushTabs(); } - - private _pushSessions = () => { - if (this._pushSessionsScheduled) - return; - this._pushSessionsScheduled = true; - queueMicrotask(async () => { - this._pushSessionsScheduled = false; - try { - const byWs = await serverRegistry.list(); - const sessions: BrowserDescriptor[] = []; - for (const list of byWs.values()) { - for (const status of list) { - if (status.title.startsWith('--playwright-internal')) - continue; - sessions.push(status); - } - } - await this._reconcile(sessions); - await this._tryRevealPending(); - this.emitSessions(sessions); - this._pushTabs(); - } catch { - // best-effort - } - }); - }; - - private async _reconcile(sessions: BrowserDescriptor[]) { - const connectable = new Map(); - for (const status of sessions) - connectable.set(status.browser.guid, status); - - for (const [guid, slot] of this._browsers) { - if (connectable.has(guid)) - continue; - if (this._attachedPage && this._attachedPage.page.context().browser() === slot.browser) { - this._attachedPage.dispose(); - this._attachedPage = undefined; - } - slot.dispose(); - this._browsers.delete(guid); - } - - for (const [guid, status] of connectable) { - if (this._browsers.has(guid)) - continue; - const slot = await BrowserTracker.create(status, { - onTabsChanged: () => this._pushTabs(), - onContextClosed: context => { - if (this._attachedPage?.page.context() === context) { - this._attachedPage.dispose(); - this._attachedPage = undefined; - } - }, - }); - if (!slot) - continue; - if (this._browsers.has(guid)) { - slot.dispose(); - continue; - } - this._browsers.set(guid, slot); - } - } - - private _findContext(params: { browser: string; context: string }): api.BrowserContext | undefined { - const slot = this._browsers.get(params.browser); - if (!slot) - return undefined; - return slot.contexts().find(c => contextId(c) === params.context); - } - - private _findPage(params: { browser: string; context: string; page: string }): api.Page | undefined { - const context = this._findContext(params); - return context?.pages().find(p => pageId(p) === params.page); - } } class AttachedPage { private _owner: DashboardConnection; - private _slot: BrowserTracker; private _page: api.Page; private _listeners: Disposable[] = []; private _screencastRunning = false; private _recordingPath: string | null = null; private _disposed = false; - constructor(owner: DashboardConnection, slot: BrowserTracker, page: api.Page) { + constructor(owner: DashboardConnection, page: api.Page) { this._owner = owner; - this._slot = slot; this._page = page; } get page(): api.Page { return this._page; } - private get _descriptor(): BrowserDescriptor { return this._slot.descriptor; } async init() { this._listeners.push( @@ -556,7 +406,7 @@ class AttachedPage { } async startRecording() { - const artifactsDir = this._descriptor.browser.launchOptions.artifactsDir ?? this._owner._recordingDir; + const artifactsDir = this._owner.artifactsDirFor(this._page.context()); this._recordingPath = path.join(artifactsDir, `recording-${Date.now()}.webm`); if (this._screencastRunning) await this._restartScreencast(this._page); diff --git a/packages/playwright-core/src/tools/dashboard/identitySessionProvider.ts b/packages/playwright-core/src/tools/dashboard/identitySessionProvider.ts new file mode 100644 index 0000000000000..13d383f835a82 --- /dev/null +++ b/packages/playwright-core/src/tools/dashboard/identitySessionProvider.ts @@ -0,0 +1,127 @@ +/** + * 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 { EventEmitter } from 'events'; +import { Disposable } from '@isomorphic/disposable'; +import { eventsHelper } from '@utils/eventsHelper'; +import { packageJSON, packageRoot } from '../../package'; +import { SessionProviderEvent } from './sessionProvider'; + +import type * as api from '../../../types/types'; +import type { BrowserDescriptor, BrowserInfo } from '../../serverRegistry'; +import type { ContextEntry, SessionProvider, SessionProviderEventMap } from './sessionProvider'; + +export class IdentitySessionProvider extends EventEmitter implements SessionProvider { + private _context: api.BrowserContext; + private _browser: api.Browser; + private _descriptor: BrowserDescriptor; + private _listeners: Disposable[] = []; + private _closed = false; + + constructor(context: api.BrowserContext) { + super(); + const browser = context.browser(); + if (!browser) + throw new Error('SingleContextDashboardProvider requires a context with an attached browser'); + this._context = context; + this._browser = browser; + this._descriptor = synthesiseDescriptor(browser); + } + + start(): void { + const emitTabsChanged = () => this.emit(SessionProviderEvent.TabsChanged); + this._listeners.push( + eventsHelper.addEventListener(this._context, 'page', emitTabsChanged), + eventsHelper.addEventListener(this._context, 'pageload', emitTabsChanged), + eventsHelper.addEventListener(this._context, 'pageclose', emitTabsChanged), + eventsHelper.addEventListener(this._context, 'framenavigated', (frame: api.Frame) => { + if (frame === frame.page().mainFrame()) + this.emit(SessionProviderEvent.TabsChanged); + }), + eventsHelper.addEventListener(this._context, 'close', () => { + this._closed = true; + this.emit(SessionProviderEvent.ContextClosed, this._context); + this.emit(SessionProviderEvent.TabsChanged); + }), + ); + this.emit(SessionProviderEvent.SessionsChanged); + this.emit(SessionProviderEvent.TabsChanged); + const firstPage = this._context.pages()[0]; + if (firstPage) + this.emit(SessionProviderEvent.AttachRequested, firstPage); + } + + dispose(): void { + this._listeners.forEach(d => d.dispose()); + this._listeners = []; + this.removeAllListeners(); + } + + async sessions(): Promise { + return this._closed ? [] : [this._descriptor]; + } + + contextEntries(): ContextEntry[] { + if (this._closed) + return []; + return [{ browser: this._browser, context: this._context, descriptor: this._descriptor }]; + } + + findContext(params: { browser: string; context: string }): api.BrowserContext | undefined { + if (this._closed) + return undefined; + if (params.browser !== this._descriptor.browser.guid) + return undefined; + if (contextId(this._context) !== params.context) + return undefined; + return this._context; + } + + findPage(params: { browser: string; context: string; page: string }): api.Page | undefined { + const context = this.findContext(params); + return context?.pages().find(p => pageId(p) === params.page); + } + + async closeSession(): Promise { + // No-op: lifecycle of the user-provided context is managed by the caller. + } +} + +function synthesiseDescriptor(browser: api.Browser): BrowserDescriptor { + const browserName = browser.browserType().name() as BrowserInfo['browserName']; + const browserInfo: BrowserInfo = { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + guid: (browser as any)._guid, + browserName, + launchOptions: {}, + }; + return { + title: 'Playwright', + playwrightVersion: packageJSON.version, + playwrightLib: packageRoot, + browser: browserInfo, + }; +} + +function pageId(p: api.Page): string { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + return (p as any)._guid; +} + +function contextId(c: api.BrowserContext): string { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + return (c as any)._guid; +} diff --git a/packages/playwright-core/src/tools/dashboard/registrySessionProvider.ts b/packages/playwright-core/src/tools/dashboard/registrySessionProvider.ts new file mode 100644 index 0000000000000..3f3fa3216d352 --- /dev/null +++ b/packages/playwright-core/src/tools/dashboard/registrySessionProvider.ts @@ -0,0 +1,226 @@ +/** + * 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 { EventEmitter } from 'events'; +import { Disposable } from '@isomorphic/disposable'; +import { eventsHelper } from '@utils/eventsHelper'; +import { connectToBrowserAcrossVersions } from '../utils/connect'; +import { serverRegistry } from '../../serverRegistry'; +import { SessionProviderEvent } from './sessionProvider'; + +import type * as api from '../../../types/types'; +import type { BrowserDescriptor } from '../../serverRegistry'; +import type { ContextEntry, SessionProvider, SessionProviderEventMap } from './sessionProvider'; + +type BrowserTrackerCallbacks = { + onTabsChanged: () => void; + onContextClosed: (context: api.BrowserContext) => void; +}; + +class BrowserTracker { + readonly descriptor: BrowserDescriptor; + readonly browser: api.Browser; + private _callbacks: BrowserTrackerCallbacks; + private _contextListeners = new Map(); + private _browserListeners: Disposable[] = []; + + static async create(descriptor: BrowserDescriptor, callbacks: BrowserTrackerCallbacks): Promise { + try { + const browser = await connectToBrowserAcrossVersions(descriptor); + const slot = new BrowserTracker(descriptor, browser, callbacks); + for (const context of browser.contexts()) + slot._wireContext(context); + slot._browserListeners.push(eventsHelper.addEventListener(browser, 'context', (context: api.BrowserContext) => { + slot._wireContext(context); + })); + return slot; + } catch { + return undefined; + } + } + + private constructor(descriptor: BrowserDescriptor, browser: api.Browser, callbacks: BrowserTrackerCallbacks) { + this.descriptor = descriptor; + this.browser = browser; + this._callbacks = callbacks; + } + + contexts(): api.BrowserContext[] { + return this.browser.contexts(); + } + + dispose() { + this._browserListeners.forEach(d => d.dispose()); + this._browserListeners = []; + for (const listeners of this._contextListeners.values()) + listeners.forEach(d => d.dispose()); + this._contextListeners.clear(); + } + + private _wireContext(context: api.BrowserContext) { + if (this._contextListeners.has(context)) + return; + const onTabsChanged = () => this._callbacks.onTabsChanged(); + const listeners: Disposable[] = [ + eventsHelper.addEventListener(context, 'page', onTabsChanged), + eventsHelper.addEventListener(context, 'pageload', onTabsChanged), + eventsHelper.addEventListener(context, 'pageclose', onTabsChanged), + eventsHelper.addEventListener(context, 'framenavigated', (frame: api.Frame) => { + if (frame === frame.page().mainFrame()) + this._callbacks.onTabsChanged(); + }), + eventsHelper.addEventListener(context, 'close', () => { + const ls = this._contextListeners.get(context); + if (ls) { + ls.forEach(d => d.dispose()); + this._contextListeners.delete(context); + } + this._callbacks.onContextClosed(context); + this._callbacks.onTabsChanged(); + }), + ]; + this._contextListeners.set(context, listeners); + this._callbacks.onTabsChanged(); + } +} + +export class RegistrySessionProvider extends EventEmitter implements SessionProvider { + private _trackers = new Map(); + private _serverRegistryDispose?: () => void; + private _pushSessionsScheduled = false; + + start(): void { + this._serverRegistryDispose = serverRegistry.watch(); + serverRegistry.on('added', this._scheduleSessions); + serverRegistry.on('removed', this._scheduleSessions); + serverRegistry.on('changed', this._scheduleSessions); + this._scheduleSessions(); + } + + dispose(): void { + serverRegistry.off('added', this._scheduleSessions); + serverRegistry.off('removed', this._scheduleSessions); + serverRegistry.off('changed', this._scheduleSessions); + this._serverRegistryDispose?.(); + this._serverRegistryDispose = undefined; + for (const tracker of this._trackers.values()) + tracker.dispose(); + this._trackers.clear(); + this.removeAllListeners(); + } + + async sessions(): Promise { + const byWs = await serverRegistry.list(); + const sessions: BrowserDescriptor[] = []; + for (const list of byWs.values()) { + for (const status of list) { + if (status.title.startsWith('--playwright-internal')) + continue; + sessions.push(status); + } + } + return sessions; + } + + contextEntries(): ContextEntry[] { + const entries: ContextEntry[] = []; + for (const tracker of this._trackers.values()) { + for (const context of tracker.contexts()) + entries.push({ browser: tracker.browser, context, descriptor: tracker.descriptor }); + } + return entries; + } + + findContext(params: { browser: string; context: string }): api.BrowserContext | undefined { + const tracker = this._trackers.get(params.browser); + if (!tracker) + return undefined; + return tracker.contexts().find(c => contextId(c) === params.context); + } + + findPage(params: { browser: string; context: string; page: string }): api.Page | undefined { + const context = this.findContext(params); + return context?.pages().find(p => pageId(p) === params.page); + } + + async closeSession(browserId: string): Promise { + const descriptor = serverRegistry.readDescriptor(browserId); + const browser = await connectToBrowserAcrossVersions(descriptor); + try { + await Promise.all(browser.contexts().map(context => context.close())); + await browser.close(); + } catch { + // best-effort + } + } + + private _scheduleSessions = () => { + if (this._pushSessionsScheduled) + return; + this._pushSessionsScheduled = true; + queueMicrotask(async () => { + this._pushSessionsScheduled = false; + try { + const sessions = await this.sessions(); + await this._reconcile(sessions); + this.emit(SessionProviderEvent.SessionsChanged); + this.emit(SessionProviderEvent.TabsChanged); + } catch { + // best-effort + } + }); + }; + + private async _reconcile(sessions: BrowserDescriptor[]) { + const connectable = new Map(); + for (const status of sessions) + connectable.set(status.browser.guid, status); + + for (const [guid, tracker] of this._trackers) { + if (connectable.has(guid)) + continue; + tracker.dispose(); + this._trackers.delete(guid); + } + + for (const [guid, status] of connectable) { + if (this._trackers.has(guid)) + continue; + const tracker = await BrowserTracker.create(status, { + onTabsChanged: () => this.emit(SessionProviderEvent.TabsChanged), + onContextClosed: context => this.emit(SessionProviderEvent.ContextClosed, context), + }); + if (!tracker) + continue; + if (this._trackers.has(guid)) { + tracker.dispose(); + continue; + } + this._trackers.set(guid, tracker); + } + } + +} + +function pageId(p: api.Page): string { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + return (p as any)._guid; +} + +function contextId(c: api.BrowserContext): string { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + return (c as any)._guid; +} diff --git a/packages/playwright-core/src/tools/dashboard/sessionProvider.ts b/packages/playwright-core/src/tools/dashboard/sessionProvider.ts new file mode 100644 index 0000000000000..e05804f19cadd --- /dev/null +++ b/packages/playwright-core/src/tools/dashboard/sessionProvider.ts @@ -0,0 +1,50 @@ +/** + * 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 { EventEmitter } from 'events'; + +import type * as api from '../../../types/types'; +import type { BrowserDescriptor } from '../../serverRegistry'; + +export type ContextEntry = { + browser: api.Browser; + context: api.BrowserContext; + descriptor: BrowserDescriptor; +}; + +export const SessionProviderEvent = { + SessionsChanged: 'sessionsChanged', + TabsChanged: 'tabsChanged', + ContextClosed: 'contextClosed', + AttachRequested: 'attachRequested', +} as const; + +export type SessionProviderEventMap = { + [SessionProviderEvent.SessionsChanged]: []; + [SessionProviderEvent.TabsChanged]: []; + [SessionProviderEvent.ContextClosed]: [context: api.BrowserContext]; + [SessionProviderEvent.AttachRequested]: [page: api.Page]; +}; + +export interface SessionProvider extends EventEmitter { + start(): void; + sessions(): Promise; + closeSession(browserId: string): Promise; + contextEntries(): ContextEntry[]; + findContext(params: { browser: string; context: string }): api.BrowserContext | undefined; + findPage(params: { browser: string; context: string; page: string }): api.Page | undefined; + dispose(): void; +} diff --git a/packages/playwright-core/src/tools/index.ts b/packages/playwright-core/src/tools/index.ts index 000ef8036db7e..24459b9b0dd19 100644 --- a/packages/playwright-core/src/tools/index.ts +++ b/packages/playwright-core/src/tools/index.ts @@ -23,6 +23,8 @@ export { browserTools, filteredTools } from './backend/tools'; export { start } from './utils/mcp/server'; export { createConnection } from './mcp/index'; export { resolveCLIConfigForCLI, resolveCLIConfigForMCP } from './mcp/config'; +export { outputDir } from './backend/context'; +export { isSystemDirectory } from '@utils/fileUtils'; export { isProfileLocked } from './mcp/browserFactory'; export { compareSemver } from './utils/socketConnection'; export { extractTrace, DirTraceLoaderBackend } from './trace/traceParser'; @@ -30,7 +32,7 @@ export { decorateMCPCommand } from './mcp/program'; export { program as cliProgram } from './cli-client/program'; export { generateHelp, generateHelpJSON } from './cli-daemon/helpGenerator'; export { decorateProgram as decorateCliDaemonProgram } from './cli-daemon/program'; -export { openDashboardApp } from './dashboard/dashboardApp'; +export { openDashboardApp, openDashboardForContext } from './dashboard/dashboardApp'; export type { ContextConfig } from './backend/context'; export type { CallToolRequest, CallToolResult, Tool } from './backend/tool'; diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index d042ec924827a..46b019eccf273 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -19,6 +19,7 @@ import path from 'path'; import os from 'os'; import dotenv from 'dotenv'; +import { isSystemDirectory } from '@utils/fileUtils'; import { playwright } from '../../inprocess'; import { configFromIniFile } from './configIni'; @@ -126,9 +127,18 @@ export async function resolveCLIConfigForMCP(cliOptions: CLIOptions, env?: NodeJ if (browser.launchOptions.headless === undefined) browser.launchOptions.headless = os.platform() === 'linux' && !process.env.DISPLAY; + validateOutputDir(result.outputDir); + return { ...result, browser, configFile }; } +function validateOutputDir(outputDir: string | undefined) { + if (!outputDir) + return; + if (isSystemDirectory(outputDir)) + throw new Error(`--output-dir cannot point to a system directory: ${path.resolve(outputDir)}.`); +} + export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionName: string, options: any, env?: NodeJS.ProcessEnv): Promise { const config = options.config ? path.resolve(options.config) : undefined; try { @@ -172,6 +182,8 @@ export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionN const browser = await validateBrowserConfig(result.browser); + validateOutputDir(result.outputDir); + if (!result.extension && !browser.isolated && !browser.userDataDir && !browser.remoteEndpoint && !browser.cdpEndpoint) { // No custom value provided, use the daemon data dir. const browserToken = browser.launchOptions?.channel ?? browser?.browserName; diff --git a/packages/playwright-core/src/tools/trace/traceUtils.ts b/packages/playwright-core/src/tools/trace/traceUtils.ts index 03bd6a461e79e..83e5b36f22deb 100644 --- a/packages/playwright-core/src/tools/trace/traceUtils.ts +++ b/packages/playwright-core/src/tools/trace/traceUtils.ts @@ -20,6 +20,7 @@ import path from 'path'; import { TraceModel, buildActionTree } from '@isomorphic/trace/traceModel'; import { TraceLoader } from '@isomorphic/trace/traceLoader'; import { renderTitleForCall } from '@isomorphic/protocolFormatter'; +import { resolveWithinRoot } from '@utils/fileUtils'; import { DirTraceLoaderBackend, extractTrace } from './traceParser'; import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; @@ -113,8 +114,11 @@ export async function saveOutputFile(fileName: string, content: string | Buffer, if (explicitOutput) { outFile = explicitOutput; } else { - await fs.promises.mkdir(cliOutputDir, { recursive: true }); - outFile = path.join(cliOutputDir, fileName); + const resolved = resolveWithinRoot(cliOutputDir, fileName); + if (!resolved) + throw new Error(`Attachment name '${fileName}' escapes output directory`); + await fs.promises.mkdir(path.dirname(resolved), { recursive: true }); + outFile = resolved; } await fs.promises.writeFile(outFile, content); return outFile; diff --git a/packages/playwright-core/src/tools/utils/mcp/server.ts b/packages/playwright-core/src/tools/utils/mcp/server.ts index df8feeb798d3e..9404ff33e0871 100644 --- a/packages/playwright-core/src/tools/utils/mcp/server.ts +++ b/packages/playwright-core/src/tools/utils/mcp/server.ts @@ -38,9 +38,6 @@ export type ClientInfo = { clientName: string; }; -export type ProgressParams = { message?: string, progress?: number, total?: number }; -export type ProgressCallback = (params: ProgressParams) => void; - class BackendManager { private _backends = new Map(); @@ -65,7 +62,7 @@ const backendManager = new BackendManager(); export interface ServerBackend { initialize?(clientInfo: ClientInfo): Promise; - callTool(name: string, args: CallToolRequest['params']['arguments'], progress: ProgressCallback): Promise; + callTool(name: string, args: CallToolRequest['params']['arguments'], signal: AbortSignal): Promise; dispose?(): Promise; } @@ -103,21 +100,6 @@ export function createServer(name: string, version: string, factory: ServerBacke server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { serverDebug('callTool', request); - const progressToken = request.params._meta?.progressToken; - let progressCounter = 0; - - const progress = progressToken ? (params: ProgressParams) => { - extra.sendNotification({ - method: 'notifications/progress', - params: { - progressToken, - progress: params.progress ?? ++progressCounter, - total: params.total, - message: params.message, - }, - }).catch(e => serverDebug('notification', e)); - } : () => {}; - try { if (!backendPromise) { backendPromise = initializeServer(server, factory, runHeartbeat).catch(e => { @@ -127,7 +109,7 @@ export function createServer(name: string, version: string, factory: ServerBacke } const backend = await backendPromise; - const toolResult = await backend.callTool(request.params.name, request.params.arguments || {}, progress); + const toolResult = await backend.callTool(request.params.name, request.params.arguments || {}, extra.signal); if (toolResult.isClose) { await backendManager.disposeBackend(backend).catch(serverDebug); backendPromise = undefined; @@ -194,7 +176,11 @@ function addServerListener(server: ServerType, event: 'close' | 'initialized', l export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number, allowedHosts?: string[], socketPath?: string } = {}) { if (options.port === undefined) { - await connect(serverBackendFactory, new StdioServerTransport(), false); + const transport = new StdioServerTransport(); + // The SDK's StdioServerTransport doesn't detect peer disconnect — it never listens for stdin + // end-of-stream. Wire it up so callTool requests can be cancelled when the client goes away. + process.stdin.on('end', () => void transport.close()); + await connect(serverBackendFactory, transport, false); return; } diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index fa9d276c7358b..f3ad17431ae25 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -100,10 +100,12 @@ function addShowReportCommand(program: Command) { command.addHelpText('afterAll', ` Arguments [report]: When specified, opens given report, otherwise opens last generated report. + Accepts a directory or a .zip archive whose top-level entry is "index.html" (e.g. one downloaded from a CI artifact). Examples: $ npx playwright show-report - $ npx playwright show-report playwright-report`); + $ npx playwright show-report playwright-report + $ npx playwright show-report playwright-report.zip`); } function addMergeReportsCommand(program: Command) { diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 457009782eb69..654bf23522dd9 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -15,6 +15,7 @@ */ import fs from 'fs'; +import os from 'os'; import path from 'path'; import { Transform } from 'stream'; @@ -22,13 +23,13 @@ import colors from 'colors/safe'; import mime from 'mime'; import open from 'open'; import * as yazl from 'yazl'; -import { assert } from '@isomorphic/assert'; import { MultiMap } from '@isomorphic/multimap'; import { calculateSha1 } from '@utils/crypto'; import { copyFileAndMakeWritable, removeFolders, sanitizeForFilePath, toPosixPath } from '@utils/fileUtils'; import { getPackageManagerExecCommand, isCodingAgent } from '@utils/env'; import { HttpServer, serveFolder } from '@utils/httpServer'; import { gracefullyProcessExitDoNotHang } from '@utils/processLauncher'; +import { extractZip } from '@utils/third_party/extractZip'; // HMR: build-time flag — `true` in watch builds, `false` in release. esbuild's // `define` in the runner bundle replaces this so the dev-server code (incl. @@ -217,12 +218,48 @@ function standaloneDefaultFolder(): string { return reportFolderFromEnv() ?? resolveReporterOutputPath('playwright-report', process.cwd(), undefined); } +async function resolveReportFolder(reportPath: string): Promise { + const stat = await fs.promises.stat(reportPath).catch(() => null); + if (!stat) + throw new Error(`No report found at "${reportPath}"`); + if (stat.isDirectory()) + return reportPath; + if (stat.isFile() && reportPath.toLowerCase().endsWith('.zip')) + return await extractReportZip(reportPath); + throw new Error(`No report found at "${reportPath}"`); +} + +async function extractReportZip(zipPath: string): Promise { + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-show-report-')); + const cleanup = () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + } + }; + // Default Node behavior on SIGINT/SIGTERM is to terminate, which fires 'exit'. + process.on('exit', cleanup); + try { + await extractZip(zipPath, { dir: tempDir }); + } catch (e) { + cleanup(); + throw new Error(`Failed to extract report from "${zipPath}": ${e.message}`); + } + const hasIndex = await fs.promises.access(path.join(tempDir, 'index.html')).then(() => true, () => false); + if (!hasIndex) { + cleanup(); + throw new Error(`No "index.html" found at the top level of "${zipPath}"`); + } + return tempDir; +} + export async function showHTMLReport(reportFolder: string | undefined, host: string = 'localhost', port?: number, testId?: string) { - const folder = reportFolder ?? standaloneDefaultFolder(); + const requestedPath = reportFolder ?? standaloneDefaultFolder(); + let folder: string; try { - assert(fs.statSync(folder).isDirectory()); + folder = await resolveReportFolder(requestedPath); } catch (e) { - writeLine(colors.red(`No report found at "${folder}"`)); + writeLine(colors.red(e.message)); gracefullyProcessExitDoNotHang(1); return; } diff --git a/packages/trace-viewer/src/ui/metadataView.tsx b/packages/trace-viewer/src/ui/metadataView.tsx index 4fc57799f021d..929a812f3fa08 100644 --- a/packages/trace-viewer/src/ui/metadataView.tsx +++ b/packages/trace-viewer/src/ui/metadataView.tsx @@ -15,6 +15,7 @@ */ import { msToString } from '@isomorphic/formatUtils'; +import { isHttpUrl } from '@isomorphic/urlMatch'; import * as React from 'react'; import type { TraceModel } from '@isomorphic/trace/traceModel'; import './callTab.css'; @@ -41,7 +42,9 @@ export const MetadataView: React.FunctionComponent<{ {model.options.baseURL && ( <>
Config
- +
baseURL:{isHttpUrl(model.options.baseURL) + ? {model.options.baseURL} + : {model.options.baseURL}}
)}
Viewport
diff --git a/packages/utils/fileUtils.ts b/packages/utils/fileUtils.ts index fefe17a30bcec..2a748a92d13ca 100644 --- a/packages/utils/fileUtils.ts +++ b/packages/utils/fileUtils.ts @@ -45,6 +45,24 @@ export function canAccessFile(file: string) { } } +export function isWritable(file: string): boolean { + try { + fs.accessSync(file, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +export function isSystemDirectory(dir: string): boolean { + const resolved = path.resolve(dir); + if (process.platform === 'win32') { + const systemRoot = path.resolve(process.env.SystemRoot || 'C:\\Windows'); + return isPathInside(systemRoot.toLowerCase(), resolved.toLowerCase()); + } + return resolved === '/'; +} + export async function copyFileAndMakeWritable(from: string, to: string) { await fs.promises.copyFile(from, to); await fs.promises.chmod(to, 0o664); diff --git a/packages/utils/wsServer.ts b/packages/utils/wsServer.ts index 1fbbaad4566bf..5eb6502aa581e 100644 --- a/packages/utils/wsServer.ts +++ b/packages/utils/wsServer.ts @@ -102,6 +102,11 @@ export class WSServer { socket.destroy(); return; } + if (this._allowedHosts && !this._isAllowedOrigin(request.headers.origin)) { + socket.write(`HTTP/${request.httpVersion} 403 Forbidden\r\n\r\n`); + socket.destroy(); + return; + } const upgradeResult = this._delegate.onUpgrade(request, socket); if (upgradeResult) { socket.write(upgradeResult.error); @@ -136,6 +141,18 @@ export class WSServer { this._delegate.onRequest(request, response); } + private _isAllowedOrigin(origin: string | undefined): boolean { + if (!origin) + return true; + try { + const hostname = new URL(origin).hostname.toLowerCase(); + const bracketed = hostname.includes(':') ? `[${hostname}]` : hostname; + return this._allowedHosts!.has(hostname) || this._allowedHosts!.has(bracketed); + } catch { + return false; + } + } + async close() { const server = this._wsServer; if (!server) diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index c34ebce180326..dce99d775f913 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -65,7 +65,6 @@ const test = baseTest.extend }, { scope: 'worker' }], browserType: [async ({ playwright, browserName, mode }, run) => { - test.skip(mode === 'service2'); await run(playwright[browserName]); }, { scope: 'worker' }], diff --git a/tests/config/testMode.ts b/tests/config/testMode.ts index 0d86aa0762a78..2664fd6742187 100644 --- a/tests/config/testMode.ts +++ b/tests/config/testMode.ts @@ -16,7 +16,7 @@ import { oop, client } from '../../packages/playwright-core/lib/coreBundle'; -export type TestModeName = 'default' | 'driver' | 'service' | 'service2' | 'wsl'; +export type TestModeName = 'default' | 'driver'; const { start } = oop; diff --git a/tests/config/testModeFixtures.ts b/tests/config/testModeFixtures.ts index e2af8fd019b7c..a174b3feeb58b 100644 --- a/tests/config/testModeFixtures.ts +++ b/tests/config/testModeFixtures.ts @@ -36,10 +36,6 @@ export const testModeTest = test.extend { const testMode = { 'default': new DefaultTestMode(), - 'service': new DefaultTestMode(), - 'service2': new DefaultTestMode(), - 'service-grid': new DefaultTestMode(), - 'wsl': new DefaultTestMode(), 'driver': new DriverTestMode(), }[mode]; const playwright = await testMode.setup(); diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts index 8876604d9bd7b..f68634fa55e0c 100644 --- a/tests/library/chromium/connect-over-cdp.spec.ts +++ b/tests/library/chromium/connect-over-cdp.spec.ts @@ -24,8 +24,6 @@ import { suppressCertificateWarning } from '../../config/utils'; const { nullProgress } = coreServer; type Frame = coreServer.Frame; -test.skip(({ mode }) => mode === 'service2'); - test('should connect to an existing cdp session', async ({ browserType, mode }, testInfo) => { const port = 9339 + testInfo.workerIndex; const browserServer = await browserType.launch({ diff --git a/tests/library/chromium/connect-to-worker.spec.ts b/tests/library/chromium/connect-to-worker.spec.ts index 43067608e7884..11acb2a5d5109 100644 --- a/tests/library/chromium/connect-to-worker.spec.ts +++ b/tests/library/chromium/connect-to-worker.spec.ts @@ -16,8 +16,6 @@ import { playwrightTest as test, expect } from '../../config/browserTest'; -test.skip(({ mode }) => mode === 'service2'); - test('should connect, evaluate, receive console and disconnect', async ({ browserType, childProcess }) => { const child = childProcess({ command: [process.execPath, '--inspect-brk=0', '-e', 'console.log("hello from node"); setTimeout(() => {}, 1e9)'] }); await child.waitForOutput('Debugger listening on ws://'); diff --git a/tests/library/playwright.config.ts b/tests/library/playwright.config.ts index e20a796a33c97..966cc30a69b9c 100644 --- a/tests/library/playwright.config.ts +++ b/tests/library/playwright.config.ts @@ -53,31 +53,9 @@ const reporters = () => { return result; }; -const os: 'linux' | 'windows' = (process.env.PLAYWRIGHT_SERVICE_OS as 'linux' | 'windows') || 'linux'; -const runId = process.env.PLAYWRIGHT_SERVICE_RUN_ID || new Date().toISOString(); // name the test run - let connectOptions: any; let webServer: Config['webServer']; -if (mode === 'service') { - connectOptions = { wsEndpoint: 'ws://localhost:3333/' }; - webServer = { - command: 'npx playwright run-server --port=3333', - url: 'http://localhost:3333', - reuseExistingServer: !process.env.CI, - }; -} -if (mode === 'service2') { - process.env.PW_VERSION_OVERRIDE = process.env.PW_VERSION_OVERRIDE || '1.39'; - connectOptions = { - wsEndpoint: `${process.env.PLAYWRIGHT_SERVICE_URL}?cap=${JSON.stringify({ os, runId })}`, - timeout: 3 * 60 * 1000, - exposeNetwork: '', - headers: { - 'x-mpt-access-key': process.env.PLAYWRIGHT_SERVICE_ACCESS_KEY! - } - }; -} if (channel === 'webkit-wsl') { connectOptions = { wsEndpoint: 'ws://localhost:3777/' }; webServer = { diff --git a/tests/library/signals.spec.ts b/tests/library/signals.spec.ts index f2a94b746f7c4..5eec86366aa87 100644 --- a/tests/library/signals.spec.ts +++ b/tests/library/signals.spec.ts @@ -23,7 +23,6 @@ import os from 'os'; test.slow(); test('should close the browser when the node process closes', async ({ startRemoteServer, isWindows, server, mode }) => { - test.skip(mode === 'wsl', 'Remove server cannot start on Windows with WebKit WSL params'); const remoteServer = await startRemoteServer('launchServer', { url: server.EMPTY_PAGE }); try { if (isWindows) diff --git a/tests/mcp/cli-fixtures.ts b/tests/mcp/cli-fixtures.ts index 887894218a7b0..8feaede0432fa 100644 --- a/tests/mcp/cli-fixtures.ts +++ b/tests/mcp/cli-fixtures.ts @@ -30,7 +30,7 @@ export const test = baseTest.extend<{ boundBrowser: Browser, cliEnv: Record, startDashboardServer: (options?: { cwd?: string, session?: string }) => Promise, - connectToDashboard: (bindTitle: string) => Promise; + connectToDashboard: () => Promise; cli: (...args: any[]) => Promise<{ output: string, error: string, @@ -60,7 +60,8 @@ export const test = baseTest.extend<{ }); }, connectToDashboard: async ({ cli, playwright }, use) => { - await use(async (bindTitle: string) => { + await use(async () => { + const bindTitle = cliEnv().PWTEST_DASHBOARD_APP_BIND_TITLE; let endpoint = ''; await expect(async () => { const { output } = await cli('list', '--all', '--json'); @@ -120,10 +121,11 @@ function cliEnv() { PLAYWRIGHT_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), PLAYWRIGHT_SOCKETS_DIR: path.join(os.tmpdir(), 'ds' + String(test.info().workerIndex)), PWTEST_CLI_CHANNEL_SCAN_DISABLED_FOR_TEST: '1', + PWTEST_DASHBOARD_APP_BIND_TITLE: `--playwright-internal--${test.info().testId}`, }; } -async function runCli(childProcess: CommonFixtures['childProcess'], args: string[], cliOptions: { cwd?: string, env?: Record, bindTitle?: string }, options: { mcpBrowser: string, mcpHeadless: boolean }) { +async function runCli(childProcess: CommonFixtures['childProcess'], args: string[], cliOptions: { cwd?: string, env?: Record }, options: { mcpBrowser: string, mcpHeadless: boolean }) { const testInfo = test.info(); const cli = childProcess({ command: [process.execPath, require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'), ...args], @@ -133,7 +135,6 @@ async function runCli(childProcess: CommonFixtures['childProcess'], args: string PLAYWRIGHT_MCP_BROWSER: options.mcpBrowser, PLAYWRIGHT_MCP_HEADLESS: String(options.mcpHeadless), PWTEST_PRINT_DASHBOARD_PID_FOR_TEST: '1', - PWTEST_DASHBOARD_APP_BIND_TITLE: cliOptions.bindTitle, ...cliOptions.env, }), }); diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index a299f81f0849c..98dd2bec49edd 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -15,7 +15,6 @@ */ import fs from 'fs'; -import os from 'os'; import path from 'path'; import { test, expect, daemonFolder } from './cli-fixtures'; import { killProcessGroup } from '../config/commonFixtures'; @@ -32,21 +31,6 @@ test('list', async ({ cli, server }) => { expect(listOutput).toContain('- default:'); }); -test('list shows sessions when cwd has no .playwright directory', async ({ cli, server }) => { - // Temp dir must have no .playwright ancestor so findWorkspaceDir returns undefined - // and the registry key falls back to workspaceDirHash. - const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pw-no-workspace-')); - try { - await cli('open', server.HELLO_WORLD, { cwd: tmpDir }); - - const { output } = await cli('list', { cwd: tmpDir }); - expect(output).toContain('### Browsers'); - expect(output).toContain('- default:'); - } finally { - await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - } -}); - test('close', async ({ cli, server }) => { await cli('open', server.HELLO_WORLD); diff --git a/tests/mcp/config-resolve.spec.ts b/tests/mcp/config-resolve.spec.ts index 5e85d48fb9d9c..f4cc2c35f26e7 100644 --- a/tests/mcp/config-resolve.spec.ts +++ b/tests/mcp/config-resolve.spec.ts @@ -15,6 +15,7 @@ */ import fs from 'fs'; +import os from 'os'; import path from 'path'; import { test, expect } from './fixtures'; @@ -22,7 +23,7 @@ import { test, expect } from './fixtures'; import { tools } from '../../packages/playwright-core/lib/coreBundle'; import type { Config } from '../../packages/playwright-core/src/tools/mcp/config.d'; -const { resolveCLIConfigForCLI, resolveCLIConfigForMCP } = tools; +const { resolveCLIConfigForCLI, resolveCLIConfigForMCP, isSystemDirectory, outputDir } = tools; // Empty env to isolate tests from the host environment. const emptyEnv = {}; @@ -260,6 +261,67 @@ test.describe('validation', () => { }); }); +// --------------------------------------------------------------------------- +// Output directory — fallback for unsuitable cwd, throw on explicit system dir +// --------------------------------------------------------------------------- + +test.describe('outputDir', () => { + test.skip(process.platform === 'win32', 'POSIX-specific cases'); + + test('falls back to tmpdir when cwd is /', () => { + const result = outputDir({ config: {}, cwd: '/' }); + expect(result).toBe(path.join(os.tmpdir(), '.playwright-mcp')); + }); + + test('uses cwd-relative .playwright-mcp for normal cwd', ({}, testInfo) => { + const cwd = testInfo.outputPath('workspace'); + fs.mkdirSync(cwd, { recursive: true }); + const result = outputDir({ config: {}, cwd }); + expect(result).toBe(path.join(cwd, '.playwright-mcp')); + }); + + test('uses .playwright-cli when skillMode is set', ({}, testInfo) => { + const cwd = testInfo.outputPath('workspace'); + fs.mkdirSync(cwd, { recursive: true }); + const result = outputDir({ config: { skillMode: true }, cwd }); + expect(result).toBe(path.join(cwd, '.playwright-cli')); + }); + + test('skillMode falls back to tmpdir/.playwright-cli when cwd is /', () => { + const result = outputDir({ config: { skillMode: true }, cwd: '/' }); + expect(result).toBe(path.join(os.tmpdir(), '.playwright-cli')); + }); + + test('explicit outputDir wins regardless of cwd', ({}, testInfo) => { + const explicit = testInfo.outputPath('explicit'); + const result = outputDir({ config: { outputDir: explicit }, cwd: '/' }); + expect(result).toBe(explicit); + }); + + test('falls back to tmpdir when cwd is not writable', ({}, testInfo) => { + const cwd = testInfo.outputPath('readonly'); + fs.mkdirSync(cwd, { recursive: true }); + fs.chmodSync(cwd, 0o500); + try { + const result = outputDir({ config: {}, cwd }); + expect(result).toBe(path.join(os.tmpdir(), '.playwright-mcp')); + } finally { + fs.chmodSync(cwd, 0o700); + } + }); + + test('isSystemDirectory detects /', () => { + expect(isSystemDirectory('/')).toBe(true); + expect(isSystemDirectory('/tmp')).toBe(false); + expect(isSystemDirectory(os.homedir())).toBe(false); + }); + + test('resolveCLIConfigForMCP throws when --output-dir is /', async () => { + await expect(resolveCLIConfigForMCP({ outputDir: '/' }, emptyEnv)) + .rejects.toThrow(/--output-dir cannot point to a system directory/); + }); +}); + // --------------------------------------------------------------------------- // MCP-specific: headless platform default, timeout CLI options // --------------------------------------------------------------------------- diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts index 74ba8fc2a68ef..36de649ea5753 100644 --- a/tests/mcp/dashboard.spec.ts +++ b/tests/mcp/dashboard.spec.ts @@ -122,13 +122,12 @@ function isAlive(pid: number): boolean { } test('daemon show: closing page exits the process', async ({ cli, connectToDashboard }) => { - const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; - const { exitCode, dashboardPid } = await cli('show', { bindTitle }); + const { exitCode, dashboardPid } = await cli('show'); expect(exitCode).toBe(0); expect(dashboardPid).toBeDefined(); expect(isAlive(dashboardPid)).toBe(true); - const browser = await connectToDashboard(bindTitle); + const browser = await connectToDashboard(); const page = browser.contexts()[0].pages()[0]; await page.close(); @@ -164,9 +163,8 @@ function verifyAnnotateOutput(output: string, expectedText: string, outputDir: s test('should capture annotations via show --annotate', async ({ connectToDashboard, cli, server }) => { await cli('open', server.EMPTY_PAGE); - const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; - await cli('show', { bindTitle }); - const browser = await connectToDashboard(bindTitle); + await cli('show'); + const browser = await connectToDashboard(); const dashboard = browser.contexts()[0].pages()[0]; await dashboard.getByRole('navigation', { name: 'Sessions' }).getByRole('option').first().click(); @@ -191,7 +189,7 @@ test('should start dashboard and annotate when no dashboard is running', async ( let done = false; void annotatePromise.finally(() => { done = true; }); - const browser = await connectToDashboard(bindTitle); + const browser = await connectToDashboard(); try { const dashboard = browser.contexts()[0].pages()[0]; await drawAndSubmitAnnotation(dashboard, 'hi'); @@ -214,7 +212,7 @@ test('should enter annotate mode on fresh dashboard.tsx mount with -s --annotate let done = false; void annotatePromise.finally(() => { done = true; }); - const browser = await connectToDashboard(bindTitle); + const browser = await connectToDashboard(); try { const dashboard = browser.contexts()[0].pages()[0]; await expect(dashboard.getByRole('main', { name: 'Dashboard: annotate' })).toBeVisible(); @@ -233,20 +231,16 @@ test('should annotate via direct browser_annotate MCP call', async ({ connectToD const page = await boundBrowser.newPage(); await page.goto(server.EMPTY_PAGE); - const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; const { client } = await startClient({ args: ['--endpoint=default', '--caps=devtools'], - env: { - ...cliEnv, - PWTEST_DASHBOARD_APP_BIND_TITLE: bindTitle, - }, + env: cliEnv, }); const annotatePromise = client.callTool({ name: 'browser_annotate' }); let done = false; void annotatePromise.then(() => { done = true; }); - const browser = await connectToDashboard(bindTitle); + const browser = await connectToDashboard(); try { const dashboard = browser.contexts()[0].pages()[0]; await expect(dashboard.getByRole('main', { name: 'Dashboard: annotate' })).toBeVisible(); @@ -262,6 +256,58 @@ test('should annotate via direct browser_annotate MCP call', async ({ connectToD expect(text).toMatch(/- \[Annotation image\]\(.*\.png\)/); }); +test('should cancel browser_annotate when the MCP request is aborted', async ({ connectToDashboard, boundBrowser, startClient, cliEnv, server }) => { + const page = await boundBrowser.newPage(); + await page.goto(server.EMPTY_PAGE); + + const { client } = await startClient({ + args: ['--endpoint=default', '--caps=devtools'], + env: cliEnv, + }); + + const controller = new AbortController(); + const annotatePromise = client.callTool({ name: 'browser_annotate' }, undefined, { signal: controller.signal }).catch(() => {}); + + const browser = await connectToDashboard(); + try { + const dashboard = browser.contexts()[0].pages()[0]; + await expect(dashboard.getByRole('main', { name: 'Dashboard: annotate' })).toBeVisible(); + + controller.abort(); + + await expect(dashboard.getByRole('main', { name: 'Dashboard', exact: true })).toBeVisible(); + } finally { + await browser.close().catch(() => {}); + } + + await annotatePromise; +}); + +test('should cancel browser_annotate when the MCP client disconnects', async ({ connectToDashboard, boundBrowser, startClient, cliEnv, server }) => { + const page = await boundBrowser.newPage(); + await page.goto(server.EMPTY_PAGE); + + const { client } = await startClient({ + args: ['--endpoint=default', '--caps=devtools'], + env: cliEnv, + }); + + void client.callTool({ name: 'browser_annotate' }).catch(() => {}); + + const browser = await connectToDashboard(); + try { + const dashboard = browser.contexts()[0].pages()[0]; + await expect(dashboard.getByRole('main', { name: 'Dashboard: annotate' })).toBeVisible(); + + await client.close(); + + await expect(dashboard.getByRole('main', { name: 'Dashboard', exact: true })).toBeVisible(); + } finally { + await browser.close().catch(() => {}); + } +}); + + test('should switch screencast to -s session on show --annotate', async ({ connectToDashboard, cli, server }) => { server.setContent('/red', '', 'text/html'); server.setContent('/green', '', 'text/html'); @@ -269,9 +315,8 @@ test('should switch screencast to -s session on show --annotate', async ({ conne await cli('-s=first', 'open', server.PREFIX + '/red'); await cli('-s=second', 'open', server.PREFIX + '/green'); - const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; - await cli('-s=first', 'show', { bindTitle }); - const browser = await connectToDashboard(bindTitle); + await cli('-s=first', 'show'); + const browser = await connectToDashboard(); const dashboard = browser.contexts()[0].pages()[0]; await expect(dashboard.locator('#display')).toBeVisible(); @@ -313,9 +358,8 @@ test('should switch screencast to -s session on show --annotate', async ({ conne test('should disengage annotate mode when --annotate client disconnects', async ({ connectToDashboard, cli, childProcess, cliEnv, mcpBrowser, mcpHeadless, server }) => { await cli('open', server.EMPTY_PAGE); - const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; - await cli('show', { bindTitle }); - const browser = await connectToDashboard(bindTitle); + await cli('show'); + const browser = await connectToDashboard(); const dashboard = browser.contexts()[0].pages()[0]; await dashboard.getByRole('navigation', { name: 'Sessions' }).getByRole('option').first().click(); diff --git a/tests/page/page-goto.spec.ts b/tests/page/page-goto.spec.ts index 3e7dffaa1c575..a3f518665bb45 100644 --- a/tests/page/page-goto.spec.ts +++ b/tests/page/page-goto.spec.ts @@ -360,23 +360,14 @@ it('should fail when main resources failed to load', async ({ page, browserName, it.skip(channel === 'webkit-wsl', 'Networking mode mirrored ends up stalling connections rather than terminating them, see https://github.com/microsoft/WSL/issues/10855.'); let error = null; await page.goto('http://localhost:44123/non-existing-url').catch(e => error = e); - if (browserName === 'chromium') { - if (mode === 'service2') - expect(error.message).toContain('net::ERR_SOCKS_CONNECTION_FAILED'); - else - expect(error.message).toContain('net::ERR_CONNECTION_REFUSED'); - } else if (browserName === 'webkit' && isWindows && mode === 'service2') { - expect(error.message).toContain(`proxy handshake error`); - } else if (browserName === 'webkit' && isWindows && channel !== 'webkit-wsl') { + if (browserName === 'chromium') + expect(error.message).toContain('net::ERR_CONNECTION_REFUSED'); + else if (browserName === 'webkit' && isWindows && channel !== 'webkit-wsl') expect(error.message).toContain(`Could not connect to server`); - } else if (browserName === 'webkit') { - if (mode === 'service2') - expect(error.message).toContain('Connection refused'); - else - expect(error.message).toContain('Could not connect'); - } else { + else if (browserName === 'webkit') + expect(error.message).toContain('Could not connect'); + else expect(error.message).toContain('NS_ERROR_CONNECTION_REFUSED'); - } }); it('should fail when exceeding maximum navigation timeout', async ({ page, server, playwright }) => { diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index fb975b2ce4973..eb2d1e58d60e7 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -17,7 +17,8 @@ import fs from 'fs'; import path from 'path'; import url from 'url'; -import { test as baseTest, expect as baseExpect, createImage } from './playwright-test-fixtures'; +import * as yazl from 'yazl'; +import { test as baseTest, expect as baseExpect, cliEntrypoint, createImage } from './playwright-test-fixtures'; import { iso, utils } from '../../packages/playwright-core/lib/coreBundle'; type HttpServer = utils.HttpServer; @@ -3524,6 +3525,84 @@ test('should support merge files option', async ({ runInlineTest, showReport, pa `); }); +test.describe('show-report .zip support', () => { + test('should serve a zipped report', async ({ runInlineTest, childProcess, findFreePort, page }, testInfo) => { + await runInlineTest({ + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('passes', async ({}) => {}); + `, + }, { reporter: 'html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + + const reportFolder = testInfo.outputPath('playwright-report'); + const zipPath = testInfo.outputPath('report.zip'); + await zipDirectory(reportFolder, zipPath); + + const port = await findFreePort(); + const proc = childProcess({ + command: ['node', cliEntrypoint, 'show-report', zipPath, `--port=${port}`], + cwd: testInfo.outputPath(), + env: { ...process.env, PLAYWRIGHT_HTML_OPEN: 'never' }, + }); + await proc.waitForOutput('Serving HTML report at'); + await page.goto(`http://localhost:${port}`); + await expect(page.locator('.subnav-item:has-text("Passed") .counter')).toHaveText('1'); + await expect(page.locator('.test-file-test-outcome-expected >> text=passes')).toBeVisible(); + }); + + test('should error on a non-zip non-directory path', async ({ runInlineTest, childProcess }, testInfo) => { + const filePath = testInfo.outputPath('not-a-report.txt'); + await fs.promises.writeFile(filePath, 'hello'); + const proc = childProcess({ + command: ['node', cliEntrypoint, 'show-report', filePath], + cwd: testInfo.outputPath(), + }); + const { exitCode } = await proc.exited; + expect(exitCode).toBe(1); + expect(proc.output).toContain(`No report found at "${filePath}"`); + }); + + test('should error when zip lacks a top-level index.html', async ({ childProcess }, testInfo) => { + const zipPath = testInfo.outputPath('nested.zip'); + const zipFile = new yazl.ZipFile(); + const finished = new Promise(resolve => zipFile.outputStream.pipe(fs.createWriteStream(zipPath)).on('close', () => resolve())); + zipFile.addBuffer(Buffer.from(''), 'nested/index.html'); + zipFile.end(); + await finished; + + const proc = childProcess({ + command: ['node', cliEntrypoint, 'show-report', zipPath], + cwd: testInfo.outputPath(), + }); + const { exitCode } = await proc.exited; + expect(exitCode).toBe(1); + expect(proc.output).toContain(`No "index.html" found at the top level of "${zipPath}"`); + }); +}); + +async function zipDirectory(sourceDir: string, zipPath: string): Promise { + const zipFile = new yazl.ZipFile(); + const finished = new Promise((resolve, reject) => { + zipFile.outputStream.pipe(fs.createWriteStream(zipPath)) + .on('close', () => resolve()) + .on('error', reject); + }); + const walk = async (dir: string, relative: string) => { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const absolute = path.join(dir, entry.name); + const relativeEntry = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) + await walk(absolute, relativeEntry); + else if (entry.isFile()) + zipFile.addFile(absolute, relativeEntry); + } + }; + await walk(sourceDir, ''); + zipFile.end(); + await finished; +} + function readAllFromStream(stream: NodeJS.ReadableStream): Promise { return new Promise(resolve => { const chunks: Buffer[] = [];