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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/todomvc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.38.0"
"@playwright/test": "^1.54.1"
}
}
12 changes: 12 additions & 0 deletions examples/todomvc/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ export default defineConfig({
},
},

{
name: 'worker',

/* Project-specific settings. */
use: {
...devices['Desktop Chrome'],
connectOptions: {
wsEndpoint: 'ws://localhost:8001/v1/playwright?session_id=12345',
},
},
},

/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-cloudflare/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
!types/**/*.d.ts

# Include playwright core and test entry points
!client.d.ts
!index.d.ts
!internal.d.ts
!test.d.ts
Expand Down
127 changes: 127 additions & 0 deletions packages/playwright-cloudflare/client.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as FS from 'fs';
import type { Browser } from './types/types';
import { chromium, request, selectors, devices } from './types/types';
import { env } from 'cloudflare:workers';

export * from './types/types';

declare module './types/types' {
interface Browser {
/**
* Get the BISO session ID associated with this browser
*
* @public
*/
sessionId(): string;
}
}

/**
* @public
*/
export interface BrowserWorker {
fetch: typeof fetch;
}

export type BrowserEndpoint = BrowserWorker | string | URL;

/**
* @public
*/
export interface AcquireResponse {
sessionId: string;
}

/**
* @public
*/
export interface ActiveSession {
sessionId: string;
startTime: number; // timestamp
// connection info, if present means there's a connection established
// from a worker to that session
connectionId?: string;
connectionStartTime?: string;
}

/**
* @public
*/
export interface ClosedSession extends ActiveSession {
endTime: number; // timestamp
closeReason: number; // close reason code
closeReasonText: string; // close reason description
}

export interface AcquireResponse {
sessionId: string;
}

/**
* @public
*/
export interface SessionsResponse {
sessions: ActiveSession[];
}

/**
* @public
*/
export interface HistoryResponse {
history: ClosedSession[];
}

/**
* @public
*/
export interface LimitsResponse {
activeSessions: Array<{id: string}>;
maxConcurrentSessions: number;
allowedBrowserAcquisitions: number; // 1 if allowed, 0 otherwise
timeUntilNextAllowedBrowserAcquisition: number;
}

/**
* @public
*/
export interface WorkersLaunchOptions {
keep_alive?: number; // milliseconds to keep browser alive even if it has no activity (from 10_000ms to 600_000ms, default is 60_000)
}

// Extracts the keys whose values match a specified type `ValueType`
type KeysByValue<T, ValueType> = {
[K in keyof T]: T[K] extends ValueType ? K : never;
}[keyof T];

export type BrowserBindingKey = KeysByValue<typeof env, BrowserWorker>;

export function connect(endpoint: BrowserWorker, options: { sessionId: string }): Promise<Browser>;

export function acquire(endpoint: BrowserEndpoint, options?: WorkersLaunchOptions): Promise<AcquireResponse>;

/**
* Returns active sessions
*
* @remarks
* Sessions with a connnectionId already have a worker connection established
*
* @param endpoint - Cloudflare worker binding
* @returns List of active sessions
*/
export function sessions(endpoint: BrowserEndpoint): Promise<ActiveSession[]>;

/**
* Returns recent sessions (active and closed)
*
* @param endpoint - Cloudflare worker binding
* @returns List of recent sessions (active and closed)
*/
export function history(endpoint: BrowserEndpoint): Promise<ClosedSession[]>;

/**
* Returns current limits
*
* @param endpoint - Cloudflare worker binding
* @returns current limits
*/
export function limits(endpoint: BrowserEndpoint): Promise<LimitsResponse>;
16 changes: 11 additions & 5 deletions packages/playwright-cloudflare/examples/todomvc/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
"wrangler": "^4.26.0"
},
"dependencies": {
"@cloudflare/playwright": "^0.0.11"
"@cloudflare/playwright": "file:../.."
}
}
76 changes: 39 additions & 37 deletions packages/playwright-cloudflare/examples/todomvc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
import { launch } from '@cloudflare/playwright';
import { expect } from '@cloudflare/playwright/test';
import fs from '@cloudflare/playwright/fs';
import { connect, acquire, BrowserWorker } from '@cloudflare/playwright/client';
import { debug } from '@cloudflare/playwright/internal';

// eslint-disable-next-line no-console
const log = console.log;

export function localBrowserSim(baseUrl: string) {
// hack to allow for local only dev, which calls a local chrome
return {
async fetch(request: string, requestInit?: RequestInit | Request): Promise<Response> {
// The puppeteer fork calls the binding with a fake host
const u = request.replace('http://fake.host', '');
log(`LOCAL ${baseUrl}${u}`);
return fetch(`${baseUrl}${u}`, requestInit).catch(err => {
log(err);
throw new Error('Unable to create new browser: code: 429: message: Too Many Requests. Local sim');
});
},
};
}

function getBrowserConnection(isLocalEnv = true): BrowserWorker {
return localBrowserSim('http://localhost:3000') as BrowserWorker;
}

export default {
async fetch(request: Request, env: Env) {
const { searchParams } = new URL(request.url);
const todos = searchParams.getAll('todo');
const trace = searchParams.has('trace');
debug.enable('pw:*');
const binding = getBrowserConnection();
const { sessionId } = await acquire(binding);
log(`Acquired session ID: ${sessionId}`);
const browser = await connect(binding, { sessionId });

const browser = await launch(env.MYBROWSER);
const page = await browser.newPage();
log(`Connected to browser with session ID: ${sessionId}`);

if (trace)
await page.context().tracing.start({ screenshots: true, snapshots: true });
const page = await browser.newPage();

await page.goto('https://demo.playwright.dev/todomvc');

const TODO_ITEMS = todos.length > 0 ? todos : [
const TODO_ITEMS = [
'buy some cheese',
'feed the cat',
'book a doctors appointment'
Expand All @@ -28,32 +49,13 @@ export default {
await newTodo.press('Enter');
}

await expect(page.getByTestId('todo-title')).toHaveCount(TODO_ITEMS.length);

await Promise.all(TODO_ITEMS.map(
(value, index) => expect(page.getByTestId('todo-title').nth(index)).toHaveText(value)
));
const img = await page.screenshot();
await browser.close();

if (trace) {
await page.context().tracing.stop({ path: 'trace.zip' });
await browser.close();
const file = await fs.promises.readFile('trace.zip');

return new Response(file, {
status: 200,
headers: {
'Content-Type': 'application/zip',
},
});
} else {
const img = await page.screenshot();
await browser.close();

return new Response(img, {
headers: {
'Content-Type': 'image/png',
},
});
}
return new Response(img, {
headers: {
'Content-Type': 'image/png',
},
});
},
};
5 changes: 3 additions & 2 deletions packages/playwright-cloudflare/examples/todomvc/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ compatibility_flags = ["nodejs_compat"]
compatibility_date = "2025-03-05"
upload_source_maps = true

[browser]
binding = "MYBROWSER"
unsafe.bindings = [
{name = "MYBROWSER", type = "browser", internal_env = "https://core-staging.rendering.cfdata.org/"},
]
6 changes: 6 additions & 0 deletions packages/playwright-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
"import": "./lib/esm/bundles/fs.js",
"require": "./lib/cjs/bundles/fs.js",
"default": "./lib/esm/bundles/fs.js"
},
"./client": {
"types": "./client.d.ts",
"import": "./lib/esm/client.js",
"require": "./lib/cjs/client.js",
"default": "./lib/esm/client.js"
}
},
"scripts": {
Expand Down
62 changes: 62 additions & 0 deletions packages/playwright-cloudflare/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* 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 { Connection } from 'playwright-core/lib/client/connection';
import { nodePlatform } from 'playwright-core/lib/utils';

import type { Browser, BrowserWorker } from '..';

export { acquire, sessions, limits, history } from './session-management';

export type Options = {
headless?: boolean;
};

export async function connect(endpoint: BrowserWorker, options: { sessionId: string }): Promise<Browser> {
const response = await endpoint.fetch(`http://fake.host/v1/playwright?browser_session=${options.sessionId}`, {
headers: {
'Upgrade': 'websocket',
},
});
const ws = response.webSocket;

if (!ws)
throw new Error('WebSocket connection not established');

ws.accept();

const connection = new Connection({
...nodePlatform,
log(name: 'api' | 'channel', message: string | Error | object) {
// eslint-disable-next-line no-console
console.debug(name, message);
},
});
connection.onmessage = message => ws.send(JSON.stringify(message));
ws.addEventListener('message', message => {
const data = message.data instanceof ArrayBuffer ? Buffer.from(message.data).toString() : message.data;
connection.dispatch(JSON.parse(data));
});
ws.addEventListener('close', () => connection.close());

const playwright = await connection.initializePlaywright();
const browser = playwright._preLaunchedBrowser() as unknown as Browser;
// TODO Hack to ensure the browser is closed (I still think websockets are not being closed properly)
(browser as any)._closedPromise = Promise.resolve();
browser.sessionId = () => options.sessionId;

return browser;
}
Loading
Loading