diff --git a/package.json b/package.json index 85682c14ae..51acdc0976 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "roll": "node utils/roll_browser.js", "check-deps": "node utils/check_deps.js", "build-android-driver": "./utils/build_android_driver.sh", - "innerloop": "playwright launch-server --browser=chromium --config=utils/innerloop-server.config.json" + "innerloop": "playwright run-server --reuse-browser" }, "workspaces": [ "packages/*" diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index 9ec3f748e9..b6a26d7f6f 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -54,7 +54,7 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { path = options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`; // 2. Start the server - const server = new PlaywrightServer(path, Infinity, false, browser); + const server = new PlaywrightServer('use-pre-launched-browser', { path, maxClients: Infinity, enableSocksProxy: false, preLaunchedBrowser: browser }); const wsEndpoint = await server.listen(options.port); // 3. Return the BrowserServer interface diff --git a/packages/playwright-core/src/cli/cli.ts b/packages/playwright-core/src/cli/cli.ts index cc6d0876ee..a47f3ebaab 100755 --- a/packages/playwright-core/src/cli/cli.ts +++ b/packages/playwright-core/src/cli/cli.ts @@ -267,12 +267,13 @@ program program .command('run-server', { hidden: true }) + .option('--reuse-browser', 'Whether to reuse the browser instance') .option('--port ', 'Server port') .option('--path ', 'Endpoint Path', '/') .option('--max-clients ', 'Maximum clients') .option('--no-socks-proxy', 'Disable Socks Proxy') .action(function(options) { - runServer(options.port ? +options.port : undefined, options.path, options.maxClients ? +options.maxClients : Infinity, options.socksProxy).catch(logErrorAndExit); + runServer(options.port ? +options.port : undefined, options.path, options.maxClients ? +options.maxClients : Infinity, options.socksProxy, options.reuseBrowser).catch(logErrorAndExit); }); program diff --git a/packages/playwright-core/src/cli/driver.ts b/packages/playwright-core/src/cli/driver.ts index 31145ad00d..830f1f18ea 100644 --- a/packages/playwright-core/src/cli/driver.ts +++ b/packages/playwright-core/src/cli/driver.ts @@ -50,8 +50,8 @@ export function runDriver() { }; } -export async function runServer(port: number | undefined, path: string = '/', maxClients: number = Infinity, enableSocksProxy: boolean = true) { - const server = await PlaywrightServer.startDefault({ path, maxClients, enableSocksProxy }); +export async function runServer(port: number | undefined, path = '/', maxClients = Infinity, enableSocksProxy = true, reuseBrowser = false) { + const server = new PlaywrightServer(reuseBrowser ? 'reuse-browser' : 'auto', { path, maxClients, enableSocksProxy }); const wsEndpoint = await server.listen(port); process.on('exit', () => server.close().catch(console.error)); console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console diff --git a/packages/playwright-core/src/grid/gridBrowserWorker.ts b/packages/playwright-core/src/grid/gridBrowserWorker.ts index 29ddabd453..7aca8dca2a 100644 --- a/packages/playwright-core/src/grid/gridBrowserWorker.ts +++ b/packages/playwright-core/src/grid/gridBrowserWorker.ts @@ -23,7 +23,7 @@ function launchGridBrowserWorker(gridURL: string, agentId: string, workerId: str const log = debug(`pw:grid:worker:${workerId}`); log('created'); const ws = new WebSocket(gridURL.replace('http://', 'ws://') + `/registerWorker?agentId=${agentId}&workerId=${workerId}`); - new PlaywrightConnection(ws, true, browserAlias, true, undefined, log, async () => { + new PlaywrightConnection('auto', ws, { enableSocksProxy: true, browserAlias, headless: true }, { playwright: null, browser: null }, log, async () => { log('exiting process'); setTimeout(() => process.exit(0), 30000); // Meanwhile, try to gracefully close all browsers. diff --git a/packages/playwright-core/src/remote/DEPS.list b/packages/playwright-core/src/remote/DEPS.list index db2dd7289b..cb14447c93 100644 --- a/packages/playwright-core/src/remote/DEPS.list +++ b/packages/playwright-core/src/remote/DEPS.list @@ -1,7 +1,7 @@ [*] ../client/ ../common/ -../dispatchers/ ../server/ +../server/dispatchers/ ../utils/ ../utilsBundle.ts diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 092af9402b..3446fe19d3 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -15,13 +15,26 @@ */ import type { WebSocket } from '../utilsBundle'; -import type { Playwright, DispatcherScope } from '../server'; +import type { Playwright, DispatcherScope, Executable } from '../server'; import { createPlaywright, DispatcherConnection, Root, PlaywrightDispatcher } from '../server'; import { Browser } from '../server/browser'; import { serverSideCallMetadata } from '../server/instrumentation'; import { gracefullyCloseAll } from '../utils/processLauncher'; import { registry } from '../server'; import { SocksProxy } from '../common/socksProxy'; +import type { Mode } from './playwrightServer'; +import { assert } from '../utils'; + +type Options = { + enableSocksProxy: boolean, + browserAlias: string | null, + headless: boolean, +}; + +type PreLaunched = { + playwright: Playwright | null; + browser: Browser | null; +}; export class PlaywrightConnection { private _ws: WebSocket; @@ -30,9 +43,18 @@ export class PlaywrightConnection { private _cleanups: (() => Promise)[] = []; private _debugLog: (m: string) => void; private _disconnected = false; + private _preLaunched: PreLaunched; + private _options: Options; + private _root: Root; - constructor(ws: WebSocket, enableSocksProxy: boolean, browserAlias: string | undefined, headless: boolean, browser: Browser | undefined, log: (m: string) => void, onClose: () => void) { + constructor(mode: Mode, ws: WebSocket, options: Options, preLaunched: PreLaunched, log: (m: string) => void, onClose: () => void) { this._ws = ws; + this._preLaunched = preLaunched; + this._options = options; + if (mode === 'reuse-browser' || mode === 'use-pre-launched-browser') + assert(preLaunched.playwright); + if (mode === 'use-pre-launched-browser') + assert(preLaunched.browser); this._onClose = onClose; this._debugLog = log; @@ -48,36 +70,36 @@ export class PlaywrightConnection { ws.on('close', () => this._onDisconnect()); ws.on('error', error => this._onDisconnect(error)); - new Root(this._dispatcherConnection, async scope => { - if (browser) - return await this._initPreLaunchedBrowserMode(scope, browser); - if (!browserAlias) - return await this._initPlaywrightConnectMode(scope, enableSocksProxy); - return await this._initLaunchBrowserMode(scope, enableSocksProxy, browserAlias, headless); + this._root = new Root(this._dispatcherConnection, async scope => { + if (mode === 'reuse-browser') + return await this._initReuseBrowsersMode(scope); + if (mode === 'use-pre-launched-browser') + return await this._initPreLaunchedBrowserMode(scope); + if (!options.browserAlias) + return await this._initPlaywrightConnectMode(scope); + return await this._initLaunchBrowserMode(scope); }); } - private async _initPlaywrightConnectMode(scope: DispatcherScope, enableSocksProxy: boolean) { + private async _initPlaywrightConnectMode(scope: DispatcherScope) { this._debugLog(`engaged playwright.connect mode`); const playwright = createPlaywright('javascript'); // Close all launched browsers on disconnect. this._cleanups.push(() => gracefullyCloseAll()); - const socksProxy = enableSocksProxy ? await this._enableSocksProxy(playwright) : undefined; + const socksProxy = this._options.enableSocksProxy ? await this._enableSocksProxy(playwright) : undefined; return new PlaywrightDispatcher(scope, playwright, socksProxy); } - private async _initLaunchBrowserMode(scope: DispatcherScope, enableSocksProxy: boolean, browserAlias: string, headless: boolean) { - this._debugLog(`engaged launch mode for "${browserAlias}"`); - const executable = registry.findExecutable(browserAlias); - if (!executable || !executable.browserName) - throw new Error(`Unsupported browser "${browserAlias}`); + private async _initLaunchBrowserMode(scope: DispatcherScope) { + this._debugLog(`engaged launch mode for "${this._options.browserAlias}"`); + const executable = this._executableForBrowerAlias(this._options.browserAlias!); const playwright = createPlaywright('javascript'); - const socksProxy = enableSocksProxy ? await this._enableSocksProxy(playwright) : undefined; - const browser = await playwright[executable.browserName].launch(serverSideCallMetadata(), { + const socksProxy = this._options.enableSocksProxy ? await this._enableSocksProxy(playwright) : undefined; + const browser = await playwright[executable.browserName!].launch(serverSideCallMetadata(), { channel: executable.type === 'browser' ? undefined : executable.name, - headless, + headless: this._options.headless, }); // Close the browser on disconnect. @@ -91,13 +113,14 @@ export class PlaywrightConnection { return new PlaywrightDispatcher(scope, playwright, socksProxy, browser); } - private async _initPreLaunchedBrowserMode(scope: DispatcherScope, browser: Browser) { + private async _initPreLaunchedBrowserMode(scope: DispatcherScope) { this._debugLog(`engaged pre-launched mode`); + const playwright = this._preLaunched.playwright!; + const browser = this._preLaunched.browser!; browser.on(Browser.Events.Disconnected, () => { // Underlying browser did close for some reason - force disconnect the client. this.close({ code: 1001, reason: 'Browser closed' }); }); - const playwright = browser.options.rootSdkObject as Playwright; const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, undefined, browser); // In pre-launched mode, keep the browser and just cleanup new contexts. // TODO: it is technically possible to launch more browsers over protocol. @@ -105,6 +128,31 @@ export class PlaywrightConnection { return playwrightDispatcher; } + private async _initReuseBrowsersMode(scope: DispatcherScope) { + this._debugLog(`engaged reuse browsers mode for ${this._options.browserAlias}`); + const executable = this._executableForBrowerAlias(this._options.browserAlias!); + const playwright = this._preLaunched.playwright!; + + let browser = playwright.allBrowsers().find(b => b.options.name === executable.browserName); + const remaining = playwright.allBrowsers().filter(b => b !== browser); + for (const r of remaining) + await r.close(); + + if (!browser) { + browser = await playwright[executable.browserName!].launch(serverSideCallMetadata(), { + channel: executable.type === 'browser' ? undefined : executable.name, + headless: false, + }); + browser.on(Browser.Events.Disconnected, () => { + // Underlying browser did close for some reason - force disconnect the client. + this.close({ code: 1001, reason: 'Browser closed' }); + }); + } + + const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, undefined, browser); + return playwrightDispatcher; + } + private async _enableSocksProxy(playwright: Playwright) { const socksProxy = new SocksProxy(); playwright.options.socksProxyPort = await socksProxy.listen(0); @@ -116,8 +164,7 @@ export class PlaywrightConnection { private async _onDisconnect(error?: Error) { this._disconnected = true; this._debugLog(`disconnected. error: ${error}`); - // Avoid sending any more messages over closed socket. - this._dispatcherConnection.onmessage = () => {}; + this._root._dispose(); this._debugLog(`starting cleanup`); for (const cleanup of this._cleanups) await cleanup().catch(() => {}); @@ -134,4 +181,11 @@ export class PlaywrightConnection { } catch (e) { } } + + private _executableForBrowerAlias(browserAlias: string): Executable { + const executable = registry.findExecutable(browserAlias); + if (!executable || !executable.browserName) + throw new Error(`Unsupported browser "${browserAlias}`); + return executable; + } } diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 39ea745bb0..8ab507db30 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -16,9 +16,12 @@ import { debug, wsServer } from '../utilsBundle'; import type { WebSocketServer } from '../utilsBundle'; -import * as http from 'http'; +import http from 'http'; import type { Browser } from '../server/browser'; +import type { Playwright } from '../server/playwright'; +import { createPlaywright } from '../server/playwright'; import { PlaywrightConnection } from './playwrightConnection'; +import { assert } from '../utils'; const debugLog = debug('pw:server'); @@ -30,24 +33,31 @@ function newLogger() { return (message: string) => debugLog(`[id=${id}] ${message}`); } +export type Mode = 'use-pre-launched-browser' | 'reuse-browser' | 'auto'; + +type ServerOptions = { + path: string; + maxClients: number; + enableSocksProxy: boolean; + preLaunchedBrowser?: Browser +}; + export class PlaywrightServer { - private _path: string; - private _maxClients: number; - private _enableSocksProxy: boolean; - private _browser: Browser | undefined; + private _preLaunchedPlaywright: Playwright | null = null; private _wsServer: WebSocketServer | undefined; private _clientsCount = 0; + private _mode: Mode; + private _options: ServerOptions; - static async startDefault(options: { path?: string, maxClients?: number, enableSocksProxy?: boolean } = {}): Promise { - const { path = '/ws', maxClients = 1, enableSocksProxy = true } = options; - return new PlaywrightServer(path, maxClients, enableSocksProxy); - } - - constructor(path: string, maxClients: number, enableSocksProxy: boolean, browser?: Browser) { - this._path = path; - this._maxClients = maxClients; - this._enableSocksProxy = enableSocksProxy; - this._browser = browser; + constructor(mode: Mode, options: ServerOptions) { + this._mode = mode; + this._options = options; + if (mode === 'use-pre-launched-browser') { + assert(options.preLaunchedBrowser); + this._preLaunchedPlaywright = options.preLaunchedBrowser.options.rootSdkObject as Playwright; + } + if (mode === 'reuse-browser') + this._preLaunchedPlaywright = createPlaywright('javascript'); } async listen(port: number = 0): Promise { @@ -63,33 +73,37 @@ export class PlaywrightServer { reject(new Error('Could not bind server socket')); return; } - const wsEndpoint = typeof address === 'string' ? `${address}${this._path}` : `ws://127.0.0.1:${address.port}${this._path}`; + const wsEndpoint = typeof address === 'string' ? `${address}${this._options.path}` : `ws://127.0.0.1:${address.port}${this._options.path}`; resolve(wsEndpoint); }).on('error', reject); }); debugLog('Listening at ' + wsEndpoint); - this._wsServer = new wsServer({ server, path: this._path }); + this._wsServer = new wsServer({ server, path: this._options.path }); const originalShouldHandle = this._wsServer.shouldHandle.bind(this._wsServer); - this._wsServer.shouldHandle = request => originalShouldHandle(request) && this._clientsCount < this._maxClients; + this._wsServer.shouldHandle = request => originalShouldHandle(request) && this._clientsCount < this._options.maxClients; this._wsServer.on('connection', async (ws, request) => { - if (this._clientsCount >= this._maxClients) { + if (this._clientsCount >= this._options.maxClients) { ws.close(1013, 'Playwright Server is busy'); return; } const url = new URL('http://localhost' + (request.url || '')); const browserHeader = request.headers['x-playwright-browser']; - const browserAlias = url.searchParams.get('browser') || (Array.isArray(browserHeader) ? browserHeader[0] : browserHeader); + const browserAlias = url.searchParams.get('browser') || (Array.isArray(browserHeader) ? browserHeader[0] : browserHeader) || null; const headlessHeader = request.headers['x-playwright-headless']; const headlessValue = url.searchParams.get('headless') || (Array.isArray(headlessHeader) ? headlessHeader[0] : headlessHeader); const proxyHeader = request.headers['x-playwright-proxy']; const proxyValue = url.searchParams.get('proxy') || (Array.isArray(proxyHeader) ? proxyHeader[0] : proxyHeader); - const enableSocksProxy = this._enableSocksProxy && proxyValue === '*'; + const enableSocksProxy = this._options.enableSocksProxy && proxyValue === '*'; this._clientsCount++; const log = newLogger(); log(`serving connection: ${request.url}`); - const connection = new PlaywrightConnection(ws, enableSocksProxy, browserAlias, headlessValue !== '0', this._browser, log, () => this._clientsCount--); + const connection = new PlaywrightConnection( + this._mode, ws, + { enableSocksProxy, browserAlias, headless: headlessValue !== '0' }, + { playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser || null }, + log, () => this._clientsCount--); (ws as any)[kConnectionSymbol] = connection; }); diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index de78bdf3ae..2e4a5e77a3 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -75,6 +75,7 @@ export abstract class Browser extends SdkObject { super(options.rootSdkObject, 'browser'); this.attribution.browser = this; this.options = options; + this.instrumentation.onBrowserOpen(this); } abstract doCreateNewContext(options: channels.BrowserNewContextParams): Promise; @@ -146,6 +147,7 @@ export abstract class Browser extends SdkObject { if (this._defaultContext) this._defaultContext._browserClosed(); this.emit(Browser.Events.Disconnected); + this.instrumentation.onBrowserClose(this); } async close() { diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 0aa9d59916..0c92f95fa4 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -159,9 +159,15 @@ export abstract class BrowserContext extends SdkObject { await this._cancelAllRoutesInFlight(); - const [page, ...otherPages] = this.pages(); - for (const page of otherPages) + let page: Page | undefined = this.pages()[0]; + const [, ...otherPages] = this.pages(); + for (const p of otherPages) + await p.close(metadata); + if (page && page._crashedPromise.isDone()) { await page.close(metadata); + page = undefined; + } + // Unless I do this early, setting extra http headers below does not respond. await page?._frameManager.closeOpenDialogs(); await page?.mainFrame().goto(metadata, 'about:blank', { timeout: 0 }); diff --git a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts index d2d851dd4c..5976cb3a50 100644 --- a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts @@ -145,7 +145,8 @@ async function newContextForReuse(browser: Browser, scope: DispatcherScope, para const { context, needsReset } = await browser.newContextForReuse(params, metadata); if (needsReset) { const oldContextDispatcher = existingDispatcher(context); - oldContextDispatcher._dispose(); + if (oldContextDispatcher) + oldContextDispatcher._dispose(); await context.resetForReuse(metadata, params); } const contextDispatcher = new BrowserContextDispatcher(scope, context); diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index 8ab414aedf..f2bf68ecb7 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -64,6 +64,8 @@ export interface Instrumentation { onEvent(sdkObject: SdkObject, metadata: CallMetadata): void; onPageOpen(page: Page): void; onPageClose(page: Page): void; + onBrowserOpen(browser: Browser): void; + onBrowserClose(browser: Browser): void; } export interface InstrumentationListener { @@ -74,6 +76,8 @@ export interface InstrumentationListener { onEvent?(sdkObject: SdkObject, metadata: CallMetadata): void; onPageOpen?(page: Page): void; onPageClose?(page: Page): void; + onBrowserOpen?(browser: Browser): void; + onBrowserClose?(browser: Browser): void; } export function createInstrumentation(): Instrumentation { diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index e27e41bf25..00bb420cef 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -16,7 +16,7 @@ import { Android } from './android/android'; import { AdbBackend } from './android/backendAdb'; -import type { PlaywrightOptions } from './browser'; +import type { Browser, PlaywrightOptions } from './browser'; import { Chromium } from './chromium/chromium'; import { Electron } from './electron/electron'; import { Firefox } from './firefox/firefox'; @@ -36,10 +36,13 @@ export class Playwright extends SdkObject { readonly webkit: WebKit; readonly options: PlaywrightOptions; private _allPages = new Set(); + private _allBrowsers = new Set(); constructor(sdkLanguage: string, isInternalPlaywright: boolean) { super({ attribution: { isInternalPlaywright }, instrumentation: createInstrumentation() } as any, undefined, 'Playwright'); this.instrumentation.addListener({ + onBrowserOpen: browser => this._allBrowsers.add(browser), + onBrowserClose: browser => this._allBrowsers.delete(browser), onPageOpen: page => this._allPages.add(page), onPageClose: page => this._allPages.delete(page), onCallLog: (sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string) => { @@ -62,6 +65,10 @@ export class Playwright extends SdkObject { async hideHighlight() { await Promise.all([...this._allPages].map(p => p.hideHighlight().catch(() => {}))); } + + allBrowsers(): Browser[] { + return [...this._allBrowsers]; + } } export function createPlaywright(sdkLanguage: string, isInternalPlaywright: boolean = false) { diff --git a/packages/playwright-test/src/index.ts b/packages/playwright-test/src/index.ts index 1c5abe2dc9..d2ad26d4c4 100644 --- a/packages/playwright-test/src/index.ts +++ b/packages/playwright-test/src/index.ts @@ -70,7 +70,7 @@ export const test = _baseTest.extend({ headless: [ ({ launchOptions }, use) => use(launchOptions.headless ?? true), { scope: 'worker', option: true } ], channel: [ ({ launchOptions }, use) => use(launchOptions.channel), { scope: 'worker', option: true } ], launchOptions: [ {}, { scope: 'worker', option: true } ], - connectOptions: [ undefined, { scope: 'worker', option: true } ], + connectOptions: [ process.env.PW_TEST_CONNECT_WS_ENDPOINT ? { wsEndpoint: process.env.PW_TEST_CONNECT_WS_ENDPOINT } : undefined, { scope: 'worker', option: true } ], screenshot: [ 'off', { scope: 'worker', option: true } ], video: [ 'off', { scope: 'worker', option: true } ], trace: [ 'off', { scope: 'worker', option: true } ], @@ -512,7 +512,7 @@ export const test = _baseTest.extend({ testInfo.errors.push({ message: prependToError }); }, { scope: 'test', _title: 'context' } as any], - _contextReuseEnabled: !!process.env.PW_REUSE_CONTEXT, + _contextReuseEnabled: !!process.env.PW_TEST_REUSE_CONTEXT, _reuseContext: async ({ video, trace, _contextReuseEnabled }, use, testInfo) => { const reuse = _contextReuseEnabled && !shouldCaptureVideo(normalizeVideoMode(video), testInfo) && !shouldCaptureTrace(normalizeTraceMode(trace), testInfo); diff --git a/tests/config/testModeFixtures.ts b/tests/config/testModeFixtures.ts index 7231548e8b..c6b2a1cfc6 100644 --- a/tests/config/testModeFixtures.ts +++ b/tests/config/testModeFixtures.ts @@ -22,12 +22,16 @@ export type TestModeWorkerOptions = { mode: TestModeName; }; -export type TestModeWorkerFixtures = { - playwright: typeof import('@playwright/test'); +export type TestModeTestFixtures = { toImpl: (rpcObject?: any) => any; }; -export const testModeTest = test.extend<{}, TestModeWorkerOptions & TestModeWorkerFixtures>({ +export type TestModeWorkerFixtures = { + toImplInWorkerScope: (rpcObject?: any) => any; + playwright: typeof import('@playwright/test'); +}; + +export const testModeTest = test.extend({ mode: [ 'default', { scope: 'worker', option: true } ], playwright: [ async ({ mode }, run) => { const testMode = { @@ -42,5 +46,13 @@ export const testModeTest = test.extend<{}, TestModeWorkerOptions & TestModeWork await testMode.teardown(); }, { scope: 'worker' } ], - toImpl: [ async ({ playwright }, run) => run((playwright as any)._toImpl), { scope: 'worker' } ], + toImplInWorkerScope: [async ({ playwright }, use) => { + await use((playwright as any)._toImpl); + }, { scope: 'worker' }], + + toImpl: async ({ toImplInWorkerScope: toImplWorker, mode }, use, testInfo) => { + if (mode !== 'default' || process.env.PW_TEST_REUSE_CONTEXT) + testInfo.skip(); + await use(toImplWorker); + }, }); diff --git a/tests/library/channels.spec.ts b/tests/library/channels.spec.ts index cba34e7442..d45239df6b 100644 --- a/tests/library/channels.spec.ts +++ b/tests/library/channels.spec.ts @@ -21,10 +21,10 @@ import { playwrightTest, expect } from '../config/browserTest'; // Use something worker-scoped (e.g. expectScopeState) forces a new worker for this file. // Otherwise, a browser launched for other tests in this worker will affect the expectations. const it = playwrightTest.extend<{}, { expectScopeState: (object: any, golden: any) => void }>({ - expectScopeState: [ async ({ toImpl }, use) => { + expectScopeState: [ async ({ toImplInWorkerScope }, use) => { await use((object, golden) => { golden = trimGuids(golden); - const remoteRoot = toImpl(); + const remoteRoot = toImplInWorkerScope(); const remoteState = trimGuids(remoteRoot._debugScopeState()); const localRoot = object._connection._rootObject; const localState = trimGuids(localRoot._debugScopeState()); diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 7517e5a17f..86b899bebd 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -186,8 +186,6 @@ playwrightTest('should connect to an existing cdp session', async ({ browserType }); playwrightTest('should cleanup artifacts dir after connectOverCDP disconnects due to ws close', async ({ browserType, toImpl, mode }, testInfo) => { - playwrightTest.skip(mode !== 'default'); - const port = 9339 + testInfo.workerIndex; const browserServer = await browserType.launch({ args: ['--remote-debugging-port=' + port] @@ -207,8 +205,6 @@ playwrightTest('should cleanup artifacts dir after connectOverCDP disconnects du }); playwrightTest('should connectOverCDP and manage downloads in default context', async ({ browserType, toImpl, mode, server }, testInfo) => { - playwrightTest.skip(mode !== 'default'); - server.setRoute('/downloadWithFilename', (req, res) => { res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', 'attachment; filename=file.txt'); diff --git a/tests/library/snapshotter.spec.ts b/tests/library/snapshotter.spec.ts index 075c59ca5f..08f0dc3b44 100644 --- a/tests/library/snapshotter.spec.ts +++ b/tests/library/snapshotter.spec.ts @@ -18,8 +18,7 @@ import { contextTest, expect } from '../config/browserTest'; import { InMemorySnapshotter } from '../../packages/playwright-core/lib/server/trace/test/inMemorySnapshotter'; const it = contextTest.extend<{ snapshotter: InMemorySnapshotter }>({ - snapshotter: async ({ mode, toImpl, context }, run, testInfo) => { - testInfo.skip(mode !== 'default'); + snapshotter: async ({ toImpl, context }, run) => { const snapshotter = new InMemorySnapshotter(toImpl(context)); await snapshotter.initialize(); await run(snapshotter); diff --git a/tests/page/elementhandle-convenience.spec.ts b/tests/page/elementhandle-convenience.spec.ts index 0d5ed7ee28..315bb5040e 100644 --- a/tests/page/elementhandle-convenience.spec.ts +++ b/tests/page/elementhandle-convenience.spec.ts @@ -95,94 +95,6 @@ it('textContent should work', async ({ page, server }) => { expect(await page.textContent('#inner')).toBe('Text,\nmore text'); }); -it('textContent should be atomic', async ({ playwright, page }) => { - const createDummySelector = () => ({ - query(root, selector) { - const result = root.querySelector(selector); - if (result) - Promise.resolve().then(() => result.textContent = 'modified'); - return result; - }, - queryAll(root: HTMLElement, selector: string) { - const result = Array.from(root.querySelectorAll(selector)); - for (const e of result) - Promise.resolve().then(() => e.textContent = 'modified'); - return result; - } - }); - await playwright.selectors.register('textContent', createDummySelector); - await page.setContent(`
Hello
`); - const tc = await page.textContent('textContent=div'); - expect(tc).toBe('Hello'); - expect(await page.evaluate(() => document.querySelector('div').textContent)).toBe('modified'); -}); - -it('innerText should be atomic', async ({ playwright, page }) => { - const createDummySelector = () => ({ - query(root: HTMLElement, selector: string) { - const result = root.querySelector(selector); - if (result) - Promise.resolve().then(() => result.textContent = 'modified'); - return result; - }, - queryAll(root: HTMLElement, selector: string) { - const result = Array.from(root.querySelectorAll(selector)); - for (const e of result) - Promise.resolve().then(() => e.textContent = 'modified'); - return result; - } - }); - await playwright.selectors.register('innerText', createDummySelector); - await page.setContent(`
Hello
`); - const tc = await page.innerText('innerText=div'); - expect(tc).toBe('Hello'); - expect(await page.evaluate(() => document.querySelector('div').innerText)).toBe('modified'); -}); - -it('innerHTML should be atomic', async ({ playwright, page }) => { - const createDummySelector = () => ({ - query(root, selector) { - const result = root.querySelector(selector); - if (result) - Promise.resolve().then(() => result.textContent = 'modified'); - return result; - }, - queryAll(root: HTMLElement, selector: string) { - const result = Array.from(root.querySelectorAll(selector)); - for (const e of result) - Promise.resolve().then(() => e.textContent = 'modified'); - return result; - } - }); - await playwright.selectors.register('innerHTML', createDummySelector); - await page.setContent(`
Helloworld
`); - const tc = await page.innerHTML('innerHTML=div'); - expect(tc).toBe('Helloworld'); - expect(await page.evaluate(() => document.querySelector('div').innerHTML)).toBe('modified'); -}); - -it('getAttribute should be atomic', async ({ playwright, page }) => { - const createDummySelector = () => ({ - query(root: HTMLElement, selector: string) { - const result = root.querySelector(selector); - if (result) - Promise.resolve().then(() => result.setAttribute('foo', 'modified')); - return result; - }, - queryAll(root: HTMLElement, selector: string) { - const result = Array.from(root.querySelectorAll(selector)); - for (const e of result) - Promise.resolve().then(() => (e as HTMLElement).setAttribute('foo', 'modified')); - return result; - } - }); - await playwright.selectors.register('getAttribute', createDummySelector); - await page.setContent(`
`); - const tc = await page.getAttribute('getAttribute=div', 'foo'); - expect(tc).toBe('hello'); - expect(await page.evaluate(() => document.querySelector('div').getAttribute('foo'))).toBe('modified'); -}); - it('isVisible and isHidden should work', async ({ page }) => { await page.setContent(`
Hi
`); @@ -308,25 +220,3 @@ it('isChecked should work', async ({ page }) => { const error = await page.isChecked('div').catch(e => e); expect(error.message).toContain('Not a checkbox or radio button'); }); - -it('isVisible should be atomic', async ({ playwright, page }) => { - const createDummySelector = () => ({ - query(root, selector) { - const result = root.querySelector(selector); - if (result) - Promise.resolve().then(() => result.style.display = 'none'); - return result; - }, - queryAll(root: HTMLElement, selector: string) { - const result = Array.from(root.querySelectorAll(selector)); - for (const e of result) - Promise.resolve().then(() => (e as HTMLElement).style.display = 'none'); - return result; - } - }); - await playwright.selectors.register('isVisible', createDummySelector); - await page.setContent(`
Hello
`); - const result = await page.isVisible('isVisible=div'); - expect(result).toBe(true); - expect(await page.evaluate(() => document.querySelector('div').style.display)).toBe('none'); -}); diff --git a/tests/page/frame-evaluate.spec.ts b/tests/page/frame-evaluate.spec.ts index 5c6f5bfd6c..b7feb2c0d6 100644 --- a/tests/page/frame-evaluate.spec.ts +++ b/tests/page/frame-evaluate.spec.ts @@ -42,8 +42,7 @@ function expectContexts(pageImpl, count, browserName) { expect(pageImpl._delegate._contextIdToContext.size).toBe(count); } -it('should dispose context on navigation', async ({ page, server, toImpl, browserName, mode, isElectron }) => { - it.skip(mode !== 'default'); +it('should dispose context on navigation', async ({ page, server, toImpl, browserName, isElectron }) => { it.skip(isElectron); await page.goto(server.PREFIX + '/frames/one-frame.html'); @@ -53,8 +52,7 @@ it('should dispose context on navigation', async ({ page, server, toImpl, browse expectContexts(toImpl(page), 2, browserName); }); -it('should dispose context on cross-origin navigation', async ({ page, server, toImpl, browserName, mode, isElectron }) => { - it.skip(mode !== 'default'); +it('should dispose context on cross-origin navigation', async ({ page, server, toImpl, browserName, isElectron }) => { it.skip(isElectron); await page.goto(server.PREFIX + '/frames/one-frame.html'); diff --git a/tests/page/page-evaluate-no-stall.spec.ts b/tests/page/page-evaluate-no-stall.spec.ts index 9e3a1f051d..39a50d7767 100644 --- a/tests/page/page-evaluate-no-stall.spec.ts +++ b/tests/page/page-evaluate-no-stall.spec.ts @@ -16,42 +16,38 @@ import { test, expect } from './pageTest'; -test.describe('non-stalling evaluate', () => { - test.skip(({ mode }) => mode !== 'default'); - - test('should work', async ({ page, server, toImpl }) => { - await page.goto(server.EMPTY_PAGE); - const result = await toImpl(page.mainFrame()).nonStallingRawEvaluateInExistingMainContext('2+2'); - expect(result).toBe(4); - }); - - test('should throw while pending navigation', async ({ page, server, toImpl }) => { - await page.goto(server.EMPTY_PAGE); - await page.evaluate(() => document.body.textContent = 'HELLO WORLD'); - let error; - await page.route('**/empty.html', async (route, request) => { - error = await toImpl(page.mainFrame()).nonStallingRawEvaluateInExistingMainContext('2+2').catch(e => e); - route.abort(); - }); - await page.goto(server.EMPTY_PAGE).catch(() => {}); - expect(error.message).toContain('Frame is currently attempting a navigation'); - }); - - test('should throw when no main execution context', async ({ page, toImpl }) => { - let errorPromise; - page.on('frameattached', frame => { - errorPromise = toImpl(frame).nonStallingRawEvaluateInExistingMainContext('2+2').catch(e => e); - }); - await page.setContent(''); - const error = await errorPromise; - // bail out if we accidentally succeeded - if (error === 4) - return; - // Testing this as a race. - expect([ - 'Frame does not yet have a main execution context', - 'Frame is currently attempting a navigation', - 'Navigation interrupted the evaluation', - ]).toContain(error.message); - }); +test('should work', async ({ page, server, toImpl }) => { + await page.goto(server.EMPTY_PAGE); + const result = await toImpl(page.mainFrame()).nonStallingRawEvaluateInExistingMainContext('2+2'); + expect(result).toBe(4); +}); + +test('should throw while pending navigation', async ({ page, server, toImpl }) => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => document.body.textContent = 'HELLO WORLD'); + let error; + await page.route('**/empty.html', async (route, request) => { + error = await toImpl(page.mainFrame()).nonStallingRawEvaluateInExistingMainContext('2+2').catch(e => e); + route.abort(); + }); + await page.goto(server.EMPTY_PAGE).catch(() => {}); + expect(error.message).toContain('Frame is currently attempting a navigation'); +}); + +test('should throw when no main execution context', async ({ page, toImpl }) => { + let errorPromise; + page.on('frameattached', frame => { + errorPromise = toImpl(frame).nonStallingRawEvaluateInExistingMainContext('2+2').catch(e => e); + }); + await page.setContent(''); + const error = await errorPromise; + // bail out if we accidentally succeeded + if (error === 4) + return; + // Testing this as a race. + expect([ + 'Frame does not yet have a main execution context', + 'Frame is currently attempting a navigation', + 'Navigation interrupted the evaluation', + ]).toContain(error.message); }); diff --git a/tests/page/page-event-crash.spec.ts b/tests/page/page-event-crash.spec.ts index 9aea198b4a..c9e87a3a68 100644 --- a/tests/page/page-event-crash.spec.ts +++ b/tests/page/page-event-crash.spec.ts @@ -22,11 +22,9 @@ function crash({ page, toImpl, browserName, platform, mode }: any) { if (browserName === 'chromium') { page.goto('chrome://crash').catch(e => {}); } else if (browserName === 'webkit') { - it.skip(mode !== 'default'); it.fixme(platform === 'darwin' && parseInt(os.release(), 10) >= 20, 'Timing out after roll on BigSur'); toImpl(page)._delegate._session.send('Page.crash', {}).catch(e => {}); } else if (browserName === 'firefox') { - it.skip(mode !== 'default'); toImpl(page)._delegate._session.send('Page.crash', {}).catch(e => {}); } } diff --git a/tests/page/pageTest.ts b/tests/page/pageTest.ts index d69b73f21e..f5439c9d28 100644 --- a/tests/page/pageTest.ts +++ b/tests/page/pageTest.ts @@ -16,7 +16,7 @@ import type { TestType } from '@playwright/test'; import type { PlatformWorkerFixtures } from '../config/platformFixtures'; -import type { TestModeWorkerFixtures, TestModeWorkerOptions } from '../config/testModeFixtures'; +import type { TestModeTestFixtures, TestModeWorkerFixtures, TestModeWorkerOptions } from '../config/testModeFixtures'; import { androidTest } from '../android/androidTest'; import { browserTest } from '../config/browserTest'; import { electronTest } from '../electron/electronTest'; @@ -24,7 +24,7 @@ import type { PageTestFixtures, PageWorkerFixtures } from './pageTestApi'; import type { ServerFixtures, ServerWorkerOptions } from '../config/serverFixtures'; export { expect } from '@playwright/test'; -let impl: TestType = browserTest; +let impl: TestType = browserTest; if (process.env.PWPAGE_IMPL === 'android') impl = androidTest; diff --git a/tests/page/selectors-register.spec.ts b/tests/page/selectors-register.spec.ts new file mode 100644 index 0000000000..5552983eb6 --- /dev/null +++ b/tests/page/selectors-register.spec.ts @@ -0,0 +1,130 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications 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 { test as it, expect } from './pageTest'; + +it.skip(!!process.env.PW_TEST_CONNECT_WS_ENDPOINT, 'selectors.register does not support reuse'); + +it('textContent should be atomic', async ({ playwright, page }) => { + const createDummySelector = () => ({ + query(root, selector) { + const result = root.querySelector(selector); + if (result) + Promise.resolve().then(() => result.textContent = 'modified'); + return result; + }, + queryAll(root: HTMLElement, selector: string) { + const result = Array.from(root.querySelectorAll(selector)); + for (const e of result) + Promise.resolve().then(() => e.textContent = 'modified'); + return result; + } + }); + await playwright.selectors.register('textContent', createDummySelector); + await page.setContent(`
Hello
`); + const tc = await page.textContent('textContent=div'); + expect(tc).toBe('Hello'); + expect(await page.evaluate(() => document.querySelector('div').textContent)).toBe('modified'); +}); + +it('innerText should be atomic', async ({ playwright, page }) => { + const createDummySelector = () => ({ + query(root: HTMLElement, selector: string) { + const result = root.querySelector(selector); + if (result) + Promise.resolve().then(() => result.textContent = 'modified'); + return result; + }, + queryAll(root: HTMLElement, selector: string) { + const result = Array.from(root.querySelectorAll(selector)); + for (const e of result) + Promise.resolve().then(() => e.textContent = 'modified'); + return result; + } + }); + await playwright.selectors.register('innerText', createDummySelector); + await page.setContent(`
Hello
`); + const tc = await page.innerText('innerText=div'); + expect(tc).toBe('Hello'); + expect(await page.evaluate(() => document.querySelector('div').innerText)).toBe('modified'); +}); + +it('innerHTML should be atomic', async ({ playwright, page }) => { + const createDummySelector = () => ({ + query(root, selector) { + const result = root.querySelector(selector); + if (result) + Promise.resolve().then(() => result.textContent = 'modified'); + return result; + }, + queryAll(root: HTMLElement, selector: string) { + const result = Array.from(root.querySelectorAll(selector)); + for (const e of result) + Promise.resolve().then(() => e.textContent = 'modified'); + return result; + } + }); + await playwright.selectors.register('innerHTML', createDummySelector); + await page.setContent(`
Helloworld
`); + const tc = await page.innerHTML('innerHTML=div'); + expect(tc).toBe('Helloworld'); + expect(await page.evaluate(() => document.querySelector('div').innerHTML)).toBe('modified'); +}); + +it('getAttribute should be atomic', async ({ playwright, page }) => { + const createDummySelector = () => ({ + query(root: HTMLElement, selector: string) { + const result = root.querySelector(selector); + if (result) + Promise.resolve().then(() => result.setAttribute('foo', 'modified')); + return result; + }, + queryAll(root: HTMLElement, selector: string) { + const result = Array.from(root.querySelectorAll(selector)); + for (const e of result) + Promise.resolve().then(() => (e as HTMLElement).setAttribute('foo', 'modified')); + return result; + } + }); + await playwright.selectors.register('getAttribute', createDummySelector); + await page.setContent(`
`); + const tc = await page.getAttribute('getAttribute=div', 'foo'); + expect(tc).toBe('hello'); + expect(await page.evaluate(() => document.querySelector('div').getAttribute('foo'))).toBe('modified'); +}); + +it('isVisible should be atomic', async ({ playwright, page }) => { + const createDummySelector = () => ({ + query(root, selector) { + const result = root.querySelector(selector); + if (result) + Promise.resolve().then(() => result.style.display = 'none'); + return result; + }, + queryAll(root: HTMLElement, selector: string) { + const result = Array.from(root.querySelectorAll(selector)); + for (const e of result) + Promise.resolve().then(() => (e as HTMLElement).style.display = 'none'); + return result; + } + }); + await playwright.selectors.register('isVisible', createDummySelector); + await page.setContent(`
Hello
`); + const result = await page.isVisible('isVisible=div'); + expect(result).toBe(true); + expect(await page.evaluate(() => document.querySelector('div').style.display)).toBe('none'); +}); diff --git a/utils/innerloop-server.config.json b/utils/innerloop-server.config.json deleted file mode 100644 index aadb799b72..0000000000 --- a/utils/innerloop-server.config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "port": 3333, - "wsPath": "/", - "headless": false -}