diff --git a/src/chromium/Browser.ts b/src/chromium/Browser.ts index 95f52f4bc9..cc616f4136 100644 --- a/src/chromium/Browser.ts +++ b/src/chromium/Browser.ts @@ -21,11 +21,12 @@ import { Events } from './events'; import { assert, helper } from '../helper'; import { BrowserContext } from './BrowserContext'; import { Connection, ConnectionEvents, CDPSession } from './Connection'; -import { Page } from './Page'; +import { Page } from '../page'; import { Target } from './Target'; import { Protocol } from './protocol'; import { Chromium } from './features/chromium'; import * as types from '../types'; +import { FrameManager } from './FrameManager'; export class Browser extends EventEmitter { private _ignoreHTTPSErrors: boolean; @@ -133,11 +134,11 @@ export class Browser extends EventEmitter { this.chromium.emit(Events.Chromium.TargetChanged, target); } - async newPage(): Promise { + async newPage(): Promise> { return this._defaultContext.newPage(); } - async _createPageInContext(contextId: string | null): Promise { + async _createPageInContext(contextId: string | null): Promise> { const { targetId } = await this._client.send('Target.createTarget', { url: 'about:blank', browserContextId: contextId || undefined }); const target = this._targets.get(targetId); assert(await target._initializedPromise, 'Failed to create target for page'); @@ -145,7 +146,7 @@ export class Browser extends EventEmitter { return page; } - async _closePage(page: Page) { + async _closePage(page: Page) { await this._client.send('Target.closeTarget', { targetId: Target.fromPage(page)._targetId }); } @@ -153,14 +154,14 @@ export class Browser extends EventEmitter { return Array.from(this._targets.values()).filter(target => target._isInitialized); } - async _pages(context: BrowserContext): Promise { + async _pages(context: BrowserContext): Promise[]> { const targets = this._allTargets().filter(target => target.browserContext() === context && target.type() === 'page'); const pages = await Promise.all(targets.map(target => target.page())); return pages.filter(page => !!page); } - async _activatePage(page: Page) { - await page._client.send('Target.activateTarget', {targetId: Target.fromPage(page)._targetId}); + async _activatePage(page: Page) { + await (page._delegate as FrameManager)._client.send('Target.activateTarget', {targetId: Target.fromPage(page)._targetId}); } async _waitForTarget(predicate: (arg0: Target) => boolean, options: { timeout?: number; } | undefined = {}): Promise { @@ -189,7 +190,7 @@ export class Browser extends EventEmitter { } } - async pages(): Promise { + async pages(): Promise[]> { const contextPages = await Promise.all(this.browserContexts().map(context => context.pages())); // Flatten array. return contextPages.reduce((acc, x) => acc.concat(x), []); diff --git a/src/chromium/BrowserContext.ts b/src/chromium/BrowserContext.ts index ec6314ff73..b20ecd8fc3 100644 --- a/src/chromium/BrowserContext.ts +++ b/src/chromium/BrowserContext.ts @@ -20,7 +20,7 @@ import { filterCookies, NetworkCookie, rewriteCookies, SetNetworkCookieParam } f import { Browser } from './Browser'; import { CDPSession } from './Connection'; import { Permissions } from './features/permissions'; -import { Page } from './Page'; +import { Page } from '../page'; export class BrowserContext { readonly permissions: Permissions; @@ -34,7 +34,7 @@ export class BrowserContext { this.permissions = new Permissions(client, contextId); } - pages(): Promise { + pages(): Promise[]> { return this._browser._pages(this); } @@ -42,7 +42,7 @@ export class BrowserContext { return !!this._id; } - newPage(): Promise { + newPage(): Promise> { return this._browser._createPageInContext(this._id); } diff --git a/src/chromium/FrameManager.ts b/src/chromium/FrameManager.ts index 1cd856eade..352d3e9239 100644 --- a/src/chromium/FrameManager.ts +++ b/src/chromium/FrameManager.ts @@ -21,18 +21,30 @@ import * as frames from '../frames'; import { assert, debugError } from '../helper'; import * as js from '../javascript'; import * as network from '../network'; -import { TimeoutSettings } from '../TimeoutSettings'; import { CDPSession } from './Connection'; import { EVALUATION_SCRIPT_URL, ExecutionContextDelegate } from './ExecutionContext'; import { DOMWorldDelegate } from './JSHandle'; import { LifecycleWatcher } from './LifecycleWatcher'; -import { NetworkManager } from './NetworkManager'; -import { Page } from './Page'; +import { NetworkManager, NetworkManagerEvents } from './NetworkManager'; +import { Page } from '../page'; import { Protocol } from './protocol'; -import { Events } from './events'; +import { Events as CommonEvents } from '../events'; import { toConsoleMessageLocation, exceptionToError, releaseObject } from './protocolHelper'; import * as dialog from '../dialog'; import * as console from '../console'; +import { PageDelegate } from '../page'; +import { RawMouseImpl, RawKeyboardImpl } from './Input'; +import { CRScreenshotDelegate } from './Screenshotter'; +import { Accessibility } from './features/accessibility'; +import { Coverage } from './features/coverage'; +import { PDF } from './features/pdf'; +import { Workers } from './features/workers'; +import { Overrides } from './features/overrides'; +import { Interception } from './features/interception'; +import { Browser } from './Browser'; +import { BrowserContext } from './BrowserContext'; +import * as types from '../types'; +import * as input from '../input'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; @@ -51,26 +63,41 @@ type FrameData = { lifecycleEvents: Set, }; -export class FrameManager extends EventEmitter implements frames.FrameDelegate { +export class FrameManager extends EventEmitter implements frames.FrameDelegate, PageDelegate { _client: CDPSession; - private _page: Page; + private _page: Page; private _networkManager: NetworkManager; - _timeoutSettings: TimeoutSettings; private _frames = new Map(); private _contextIdToContext = new Map(); private _isolatedWorlds = new Set(); private _mainFrame: frames.Frame; + rawMouse: RawMouseImpl; + rawKeyboard: RawKeyboardImpl; + screenshotterDelegate: CRScreenshotDelegate; - constructor(client: CDPSession, page: Page, ignoreHTTPSErrors: boolean, timeoutSettings: TimeoutSettings) { + constructor(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean) { super(); this._client = client; - this._page = page; + this.rawKeyboard = new RawKeyboardImpl(client); + this.rawMouse = new RawMouseImpl(client); + this.screenshotterDelegate = new CRScreenshotDelegate(client); this._networkManager = new NetworkManager(client, ignoreHTTPSErrors, this); - this._timeoutSettings = timeoutSettings; + this._page = new Page(this, browserContext, ignoreHTTPSErrors); + (this._page as any).accessibility = new Accessibility(client); + (this._page as any).coverage = new Coverage(client); + (this._page as any).pdf = new PDF(client); + (this._page as any).workers = new Workers(client, this._page._addConsoleMessage.bind(this._page), error => this._page.emit(CommonEvents.Page.PageError, error)); + (this._page as any).overrides = new Overrides(client); + (this._page as any).interception = new Interception(this._networkManager); + + this._networkManager.on(NetworkManagerEvents.Request, event => this._page.emit(CommonEvents.Page.Request, event)); + this._networkManager.on(NetworkManagerEvents.Response, event => this._page.emit(CommonEvents.Page.Response, event)); + this._networkManager.on(NetworkManagerEvents.RequestFailed, event => this._page.emit(CommonEvents.Page.RequestFailed, event)); + this._networkManager.on(NetworkManagerEvents.RequestFinished, event => this._page.emit(CommonEvents.Page.RequestFinished, event)); this._client.on('Inspector.targetCrashed', event => this._onTargetCrashed()); this._client.on('Log.entryAdded', event => this._onLogEntryAdded(event)); - this._client.on('Page.domContentEventFired', event => page.emit(Events.Page.DOMContentLoaded)); + this._client.on('Page.domContentEventFired', event => this._page.emit(CommonEvents.Page.DOMContentLoaded)); this._client.on('Page.fileChooserOpened', event => this._onFileChooserOpened(event)); this._client.on('Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)); this._client.on('Page.frameDetached', event => this._onFrameDetached(event.frameId)); @@ -78,7 +105,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { this._client.on('Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)); this._client.on('Page.javascriptDialogOpening', event => this._onDialog(event)); this._client.on('Page.lifecycleEvent', event => this._onLifecycleEvent(event)); - this._client.on('Page.loadEventFired', event => page.emit(Events.Page.Load)); + this._client.on('Page.loadEventFired', event => this._page.emit(CommonEvents.Page.Load)); this._client.on('Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)); this._client.on('Runtime.bindingCalled', event => this._onBindingCalled(event)); this._client.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event)); @@ -119,7 +146,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { const { referer = this._networkManager.extraHTTPHeaders()['referer'], waitUntil = ['load'], - timeout = this._timeoutSettings.navigationTimeout(), + timeout = this._page._timeoutSettings.navigationTimeout(), } = options; const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); @@ -157,7 +184,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { assertNoLegacyNavigationOptions(options); const { waitUntil = ['load'], - timeout = this._timeoutSettings.navigationTimeout(), + timeout = this._page._timeoutSettings.navigationTimeout(), } = options; const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); const error = await Promise.race([ @@ -174,7 +201,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { async setFrameContent(frame: frames.Frame, html: string, options: frames.NavigateOptions = {}) { const { waitUntil = ['load'], - timeout = this._timeoutSettings.navigationTimeout(), + timeout = this._page._timeoutSettings.navigationTimeout(), } = options; const context = await frame._utilityContext(); // We rely upon the fact that document.open() will reset frame lifecycle with "init" @@ -228,7 +255,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { this._handleFrameTree(child); } - page(): Page { + page(): Page { return this._page; } @@ -249,7 +276,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { return; assert(parentFrameId); const parentFrame = this._frames.get(parentFrameId); - const frame = new frames.Frame(this, this._timeoutSettings, parentFrame); + const frame = new frames.Frame(this, this._page._timeoutSettings, parentFrame); const data: FrameData = { id: frameId, loaderId: '', @@ -258,6 +285,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { frame[frameDataSymbol] = data; this._frames.set(frameId, frame); this.emit(FrameManagerEvents.FrameAttached, frame); + this._page.emit(CommonEvents.Page.FrameAttached, frame); } _onFrameNavigated(framePayload: Protocol.Page.Frame) { @@ -280,7 +308,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { data.id = framePayload.id; } else { // Initial main frame navigation. - frame = new frames.Frame(this, this._timeoutSettings, null); + frame = new frames.Frame(this, this._page._timeoutSettings, null); const data: FrameData = { id: framePayload.id, loaderId: '', @@ -296,6 +324,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { frame._navigated(framePayload.url, framePayload.name); this.emit(FrameManagerEvents.FrameNavigated, frame); + this._page.emit(CommonEvents.Page.FrameNavigated, frame); } async _ensureIsolatedWorld(name: string) { @@ -320,6 +349,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { frame._navigated(url, frame.name()); this.emit(FrameManagerEvents.FrameNavigatedWithinDocument, frame); this.emit(FrameManagerEvents.FrameNavigated, frame); + this._page.emit(CommonEvents.Page.FrameNavigated, frame); } _onFrameDetached(frameId: string) { @@ -371,6 +401,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { frame._detach(); this._frames.delete(this._frameData(frame).id); this.emit(FrameManagerEvents.FrameDetached, frame); + this._page.emit(CommonEvents.Page.FrameDetached, frame); } async _onConsoleAPI(event: Protocol.Runtime.consoleAPICalledPayload) { @@ -395,7 +426,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace)); } - async _exposeBinding(name: string, bindingFunction: string) { + async exposeBinding(name: string, bindingFunction: string) { await this._client.send('Runtime.addBinding', {name: name}); await this._client.send('Page.addScriptToEvaluateOnNewDocument', {source: bindingFunction}); await Promise.all(this.frames().map(frame => frame.evaluate(bindingFunction).catch(debugError))); @@ -407,7 +438,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { } _onDialog(event : Protocol.Page.javascriptDialogOpeningPayload) { - this._page.emit(Events.Page.Dialog, new dialog.Dialog( + this._page.emit(CommonEvents.Page.Dialog, new dialog.Dialog( event.type as dialog.DialogType, event.message, async (accept: boolean, promptText?: string) => { @@ -417,7 +448,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { } _handleException(exceptionDetails: Protocol.Runtime.ExceptionDetails) { - this._page.emit(Events.Page.PageError, exceptionToError(exceptionDetails)); + this._page.emit(CommonEvents.Page.PageError, exceptionToError(exceptionDetails)); } _onTargetCrashed() { @@ -429,7 +460,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { if (args) args.map(arg => releaseObject(this._client, arg)); if (source !== 'worker') - this._page.emit(Events.Page.Console, new console.ConsoleMessage(level, text, [], {url, lineNumber})); + this._page.emit(CommonEvents.Page.Console, new console.ConsoleMessage(level, text, [], {url, lineNumber})); } async _onFileChooserOpened(event: Protocol.Page.fileChooserOpenedPayload) { @@ -438,6 +469,88 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate { const handle = await (utilityWorld.delegate as DOMWorldDelegate).adoptBackendNodeId(event.backendNodeId, utilityWorld); this._page._onFileChooserOpened(handle); } + + setExtraHTTPHeaders(extraHTTPHeaders: network.Headers): Promise { + return this._networkManager.setExtraHTTPHeaders(extraHTTPHeaders); + } + + setUserAgent(userAgent: string): Promise { + return this._networkManager.setUserAgent(userAgent); + } + + async setJavaScriptEnabled(enabled: boolean): Promise { + await this._client.send('Emulation.setScriptExecutionDisabled', { value: !enabled }); + } + + async setBypassCSP(enabled: boolean): Promise { + await this._client.send('Page.setBypassCSP', { enabled }); + } + + async setViewport(viewport: types.Viewport): Promise { + const { + width, + height, + isMobile = false, + deviceScaleFactor = 1, + hasTouch = false, + isLandscape = false, + } = viewport; + const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' }; + await Promise.all([ + this._client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation }), + this._client.send('Emulation.setTouchEmulationEnabled', { + enabled: hasTouch + }) + ]); + } + + async setEmulateMedia(mediaType: input.MediaType | null, mediaColorScheme: input.MediaColorScheme | null): Promise { + const features = mediaColorScheme ? [{ name: 'prefers-color-scheme', value: mediaColorScheme }] : []; + await this._client.send('Emulation.setEmulatedMedia', { media: mediaType || '', features }); + } + + setCacheEnabled(enabled: boolean): Promise { + return this._networkManager.setCacheEnabled(enabled); + } + + async reload(options?: frames.NavigateOptions): Promise { + const [response] = await Promise.all([ + this._page.waitForNavigation(options), + this._client.send('Page.reload') + ]); + return response; + } + + private async _go(delta: number, options?: frames.NavigateOptions): Promise { + const history = await this._client.send('Page.getNavigationHistory'); + const entry = history.entries[history.currentIndex + delta]; + if (!entry) + return null; + const [response] = await Promise.all([ + this._page.waitForNavigation(options), + this._client.send('Page.navigateToHistoryEntry', {entryId: entry.id}), + ]); + return response; + } + + goBack(options?: frames.NavigateOptions): Promise { + return this._go(-1, options); + } + + goForward(options?: frames.NavigateOptions): Promise { + return this._go(+1, options); + } + + async evaluateOnNewDocument(source: string): Promise { + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source }); + } + + async closePage(runBeforeUnload: boolean): Promise { + if (runBeforeUnload) + await this._client.send('Page.close'); + else + await this._page.browser()._closePage(this._page); + } } function assertNoLegacyNavigationOptions(options) { diff --git a/src/chromium/JSHandle.ts b/src/chromium/JSHandle.ts index ca965a6c8c..9e1c6918d8 100644 --- a/src/chromium/JSHandle.ts +++ b/src/chromium/JSHandle.ts @@ -50,7 +50,7 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { } isJavascriptEnabled(): boolean { - return this._frameManager.page()._javascriptEnabled; + return this._frameManager.page()._state.javascriptEnabled; } isElement(remoteObject: any): boolean { diff --git a/src/chromium/Target.ts b/src/chromium/Target.ts index 0127b27f25..12b5ab27b0 100644 --- a/src/chromium/Target.ts +++ b/src/chromium/Target.ts @@ -19,11 +19,12 @@ import * as types from '../types'; import { Browser } from './Browser'; import { BrowserContext } from './BrowserContext'; import { CDPSession, CDPSessionEvents } from './Connection'; -import { Events } from './events'; +import { Events as CommonEvents } from '../events'; import { Worker } from './features/workers'; -import { Page } from './Page'; +import { Page } from '../page'; import { Protocol } from './protocol'; import { debugError } from '../helper'; +import { FrameManager } from './FrameManager'; const targetSymbol = Symbol('target'); @@ -34,14 +35,14 @@ export class Target { private _sessionFactory: () => Promise; private _ignoreHTTPSErrors: boolean; private _defaultViewport: types.Viewport; - private _pagePromise: Promise | null = null; - private _page: Page | null = null; + private _pagePromise: Promise> | null = null; + private _page: Page | null = null; private _workerPromise: Promise | null = null; _initializedPromise: Promise; _initializedCallback: (value?: unknown) => void; _isInitialized: boolean; - static fromPage(page: Page): Target { + static fromPage(page: Page): Target { return (page as any)[targetSymbol]; } @@ -64,10 +65,10 @@ export class Target { if (!opener || !opener._pagePromise || this.type() !== 'page') return true; const openerPage = await opener._pagePromise; - if (!openerPage.listenerCount(Events.Page.Popup)) + if (!openerPage.listenerCount(CommonEvents.Page.Popup)) return true; const popupPage = await this.page(); - openerPage.emit(Events.Page.Popup, popupPage); + openerPage.emit(CommonEvents.Page.Popup, popupPage); return true; }); this._isInitialized = this._targetInfo.type !== 'page' || this._targetInfo.url !== ''; @@ -80,10 +81,11 @@ export class Target { this._page._didClose(); } - async page(): Promise { + async page(): Promise | null> { if ((this._targetInfo.type === 'page' || this._targetInfo.type === 'background_page') && !this._pagePromise) { this._pagePromise = this._sessionFactory().then(async client => { - const page = new Page(client, this._browserContext, this._ignoreHTTPSErrors); + const frameManager = new FrameManager(client, this._browserContext, this._ignoreHTTPSErrors); + const page = frameManager.page(); this._page = page; page[targetSymbol] = this; client.once(CDPSessionEvents.Disconnected, () => page._didDisconnect()); @@ -93,7 +95,7 @@ export class Target { client.send('Target.detachFromTarget', { sessionId: event.sessionId }).catch(debugError); } }); - await page._frameManager.initialize(); + await frameManager.initialize(); await client.send('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: false, flatten: true}); if (this._defaultViewport) await page.setViewport(this._defaultViewport); diff --git a/src/chromium/api.ts b/src/chromium/api.ts index 728e9ba384..360089e0b5 100644 --- a/src/chromium/api.ts +++ b/src/chromium/api.ts @@ -21,7 +21,7 @@ export { Overrides } from './features/overrides'; export { PDF } from './features/pdf'; export { Permissions } from './features/permissions'; export { Worker, Workers } from './features/workers'; -export { Page } from './Page'; +export { Page } from '../page'; export { Playwright } from './Playwright'; export { Target } from './Target'; diff --git a/src/chromium/events.ts b/src/chromium/events.ts index 87f200c65a..e6680b26b2 100644 --- a/src/chromium/events.ts +++ b/src/chromium/events.ts @@ -16,26 +16,6 @@ */ export const Events = { - Page: { - Close: 'close', - Console: 'console', - Dialog: 'dialog', - FileChooser: 'filechooser', - DOMContentLoaded: 'domcontentloaded', - // Can't use just 'error' due to node.js special treatment of error events. - // @see https://nodejs.org/api/events.html#events_error_events - PageError: 'pageerror', - Request: 'request', - Response: 'response', - RequestFailed: 'requestfailed', - RequestFinished: 'requestfinished', - FrameAttached: 'frameattached', - FrameDetached: 'framedetached', - FrameNavigated: 'framenavigated', - Load: 'load', - Popup: 'popup', - }, - Browser: { Disconnected: 'disconnected' }, diff --git a/src/chromium/features/chromium.ts b/src/chromium/features/chromium.ts index 8aa84f5ab3..bf16aca0a8 100644 --- a/src/chromium/features/chromium.ts +++ b/src/chromium/features/chromium.ts @@ -19,10 +19,11 @@ import { assert } from '../../helper'; import { Browser } from '../Browser'; import { BrowserContext } from '../BrowserContext'; import { CDPSession, Connection } from '../Connection'; -import { Page } from '../Page'; +import { Page } from '../../page'; import { readProtocolStream } from '../protocolHelper'; import { Target } from '../Target'; import { Worker } from './workers'; +import { FrameManager } from '../FrameManager'; export class Chromium extends EventEmitter { private _connection: Connection; @@ -47,9 +48,9 @@ export class Chromium extends EventEmitter { return target._worker(); } - async startTracing(page: Page | undefined, options: { path?: string; screenshots?: boolean; categories?: string[]; } = {}) { + async startTracing(page: Page | undefined, options: { path?: string; screenshots?: boolean; categories?: string[]; } = {}) { assert(!this._recording, 'Cannot start recording trace while already recording trace.'); - this._tracingClient = page ? page._client : this._client; + this._tracingClient = page ? (page._delegate as FrameManager)._client : this._client; const defaultCategories = [ '-*', 'devtools.timeline', 'v8.execute', 'disabled-by-default-devtools.timeline', @@ -91,7 +92,7 @@ export class Chromium extends EventEmitter { return context ? targets.filter(t => t.browserContext() === context) : targets; } - pageTarget(page: Page): Target { + pageTarget(page: Page): Target { return Target.fromPage(page); } diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000000..5e0f2292f0 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,38 @@ +/** + * Copyright 2019 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. + */ + +export const Events = { + Page: { + Close: 'close', + Console: 'console', + Dialog: 'dialog', + FileChooser: 'filechooser', + DOMContentLoaded: 'domcontentloaded', + // Can't use just 'error' due to node.js special treatment of error events. + // @see https://nodejs.org/api/events.html#events_error_events + PageError: 'pageerror', + Request: 'request', + Response: 'response', + RequestFailed: 'requestfailed', + RequestFinished: 'requestfinished', + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + FrameNavigated: 'framenavigated', + Load: 'load', + Popup: 'popup', + }, +}; diff --git a/src/firefox/Page.ts b/src/firefox/Page.ts index 715164cc1c..b44c900b01 100644 --- a/src/firefox/Page.ts +++ b/src/firefox/Page.ts @@ -113,8 +113,8 @@ export class Page extends EventEmitter { } async emulateMedia(options: { - type?: ''|'screen'|'print', - colorScheme?: 'dark' | 'light' | 'no-preference' }) { + type?: input.MediaType, + colorScheme?: input.MediaColorScheme }) { assert(!options.type || input.mediaTypes.has(options.type), 'Unsupported media type: ' + options.type); assert(!options.colorScheme || input.mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme); await this._session.send('Page.setEmulatedMedia', options); diff --git a/src/input.ts b/src/input.ts index 829886ded1..d41f28b42d 100644 --- a/src/input.ts +++ b/src/input.ts @@ -379,5 +379,7 @@ export type FilePayload = { data: string }; -export const mediaTypes = new Set(['screen', 'print']); -export const mediaColorSchemes = new Set(['dark', 'light', 'no-preference']); +export type MediaType = 'screen' | 'print'; +export const mediaTypes: Set = new Set(['screen', 'print']); +export type MediaColorScheme = 'dark' | 'light' | 'no-preference'; +export const mediaColorSchemes: Set = new Set(['dark', 'light', 'no-preference']); diff --git a/src/chromium/Page.ts b/src/page.ts similarity index 57% rename from src/chromium/Page.ts rename to src/page.ts index 8441440ad8..7cf697f3f0 100644 --- a/src/chromium/Page.ts +++ b/src/page.ts @@ -16,86 +16,97 @@ */ import { EventEmitter } from 'events'; -import * as console from '../console'; -import * as dom from '../dom'; -import * as frames from '../frames'; -import { assert, debugError, helper } from '../helper'; -import * as input from '../input'; -import { ClickOptions, mediaColorSchemes, mediaTypes, MultiClickOptions, PointerActionOptions, SelectOption } from '../input'; -import * as js from '../javascript'; -import * as network from '../network'; -import { Screenshotter } from '../screenshotter'; -import { TimeoutSettings } from '../TimeoutSettings'; -import * as types from '../types'; -import { Browser } from './Browser'; -import { BrowserContext } from './BrowserContext'; -import { CDPSession } from './Connection'; +import * as console from './console'; +import * as dom from './dom'; +import * as frames from './frames'; +import { assert, debugError, helper } from './helper'; +import * as input from './input'; +import * as js from './javascript'; +import * as network from './network'; +import { Screenshotter, ScreenshotterDelegate } from './screenshotter'; +import { TimeoutSettings } from './TimeoutSettings'; +import * as types from './types'; import { Events } from './events'; -import { Accessibility } from './features/accessibility'; -import { Coverage } from './features/coverage'; -import { Interception } from './features/interception'; -import { Overrides } from './features/overrides'; -import { PDF } from './features/pdf'; -import { Workers } from './features/workers'; -import { FrameManager, FrameManagerEvents } from './FrameManager'; -import { RawKeyboardImpl, RawMouseImpl } from './Input'; -import { NetworkManagerEvents } from './NetworkManager'; -import { CRScreenshotDelegate } from './Screenshotter'; -import { Protocol } from './protocol'; -export class Page extends EventEmitter { +export interface PageDelegate { + readonly rawMouse: input.RawMouse; + readonly rawKeyboard: input.RawKeyboard; + readonly screenshotterDelegate: ScreenshotterDelegate; + mainFrame(): frames.Frame; + frames(): frames.Frame[]; + reload(options?: frames.NavigateOptions): Promise; + goBack(options?: frames.NavigateOptions): Promise; + goForward(options?: frames.NavigateOptions): Promise; + exposeBinding(name: string, bindingFunction: string): Promise; + evaluateOnNewDocument(source: string): Promise; + closePage(runBeforeUnload: boolean): Promise; + + setExtraHTTPHeaders(extraHTTPHeaders: network.Headers): Promise; + setUserAgent(userAgent: string): Promise; + setJavaScriptEnabled(enabled: boolean): Promise; + setBypassCSP(enabled: boolean): Promise; + setViewport(viewport: types.Viewport): Promise; + setEmulateMedia(mediaType: input.MediaType | null, mediaColorScheme: input.MediaColorScheme | null): Promise; + setCacheEnabled(enabled: boolean): Promise; +} + +interface BrowserContextInterface { + browser(): Browser; +} + +type PageState = { + viewport: types.Viewport | null; + userAgent: string | null; + mediaType: input.MediaType | null; + mediaColorScheme: input.MediaColorScheme | null; + javascriptEnabled: boolean | null; + extraHTTPHeaders: network.Headers | null; + bypassCSP: boolean | null; + cacheEnabled: boolean | null; +}; + +export type FileChooser = { + element: dom.ElementHandle, + multiple: boolean +}; + +export class Page> extends EventEmitter { private _closed = false; private _closedCallback: () => void; private _closedPromise: Promise; private _disconnected = false; private _disconnectedCallback: (e: Error) => void; private _disconnectedPromise: Promise; - _client: CDPSession; private _browserContext: BrowserContext; readonly keyboard: input.Keyboard; readonly mouse: input.Mouse; - private _timeoutSettings: TimeoutSettings; - _frameManager: FrameManager; - readonly accessibility: Accessibility; - readonly coverage: Coverage; - readonly overrides: Overrides; - readonly interception: Interception; - readonly pdf: PDF; - readonly workers: Workers; + readonly _timeoutSettings: TimeoutSettings; + readonly _delegate: PageDelegate; + readonly _state: PageState; private _pageBindings = new Map(); - _javascriptEnabled = true; - private _viewport: types.Viewport | null = null; - _screenshotter: Screenshotter; + readonly _screenshotter: Screenshotter; private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>(); - private _emulatedMediaType: string | undefined; - constructor(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean) { + constructor(delegate: PageDelegate, browserContext: BrowserContext, ignoreHTTPSErrors: boolean) { super(); - this._client = client; + this._delegate = delegate; this._closedPromise = new Promise(f => this._closedCallback = f); this._disconnectedPromise = new Promise(f => this._disconnectedCallback = f); this._browserContext = browserContext; - this.keyboard = new input.Keyboard(new RawKeyboardImpl(client)); - this.mouse = new input.Mouse(new RawMouseImpl(client), this.keyboard); + this._state = { + viewport: null, + userAgent: null, + mediaType: null, + mediaColorScheme: null, + javascriptEnabled: null, + extraHTTPHeaders: null, + bypassCSP: null, + cacheEnabled: null, + }; + this.keyboard = new input.Keyboard(delegate.rawKeyboard); + this.mouse = new input.Mouse(delegate.rawMouse, this.keyboard); this._timeoutSettings = new TimeoutSettings(); - this.accessibility = new Accessibility(client); - this._frameManager = new FrameManager(client, this, ignoreHTTPSErrors, this._timeoutSettings); - this.coverage = new Coverage(client); - this.pdf = new PDF(client); - this.workers = new Workers(client, this._addConsoleMessage.bind(this), error => this.emit(Events.Page.PageError, error)); - this.overrides = new Overrides(client); - this.interception = new Interception(this._frameManager.networkManager()); - this._screenshotter = new Screenshotter(this, new CRScreenshotDelegate(this._client), browserContext.browser()); - - this._frameManager.on(FrameManagerEvents.FrameAttached, event => this.emit(Events.Page.FrameAttached, event)); - this._frameManager.on(FrameManagerEvents.FrameDetached, event => this.emit(Events.Page.FrameDetached, event)); - this._frameManager.on(FrameManagerEvents.FrameNavigated, event => this.emit(Events.Page.FrameNavigated, event)); - - const networkManager = this._frameManager.networkManager(); - networkManager.on(NetworkManagerEvents.Request, event => this.emit(Events.Page.Request, event)); - networkManager.on(NetworkManagerEvents.Response, event => this.emit(Events.Page.Response, event)); - networkManager.on(NetworkManagerEvents.RequestFailed, event => this.emit(Events.Page.RequestFailed, event)); - networkManager.on(NetworkManagerEvents.RequestFinished, event => this.emit(Events.Page.RequestFinished, event)); + this._screenshotter = new Screenshotter(this, delegate.screenshotterDelegate, browserContext.browser()); } _didClose() { @@ -147,11 +158,11 @@ export class Page extends EventEmitter { } mainFrame(): frames.Frame { - return this._frameManager.mainFrame(); + return this._delegate.mainFrame(); } frames(): frames.Frame[] { - return this._frameManager.frames(); + return this._delegate.frames(); } setDefaultNavigationTimeout(timeout: number) { @@ -199,7 +210,7 @@ export class Page extends EventEmitter { if (this._pageBindings.has(name)) throw new Error(`Failed to add page binding with name ${name}: window['${name}'] already exists!`); this._pageBindings.set(name, playwrightFunction); - await this._frameManager._exposeBinding(name, helper.evaluationString(addPageBinding, name)); + await this._delegate.exposeBinding(name, helper.evaluationString(addPageBinding, name)); function addPageBinding(bindingName: string) { const binding = window[bindingName]; @@ -219,12 +230,14 @@ export class Page extends EventEmitter { } } - async setExtraHTTPHeaders(headers: { [s: string]: string; }) { - return this._frameManager.networkManager().setExtraHTTPHeaders(headers); + setExtraHTTPHeaders(headers: network.Headers) { + this._state.extraHTTPHeaders = {...headers}; + return this._delegate.setExtraHTTPHeaders(headers); } - async setUserAgent(userAgent: string) { - return this._frameManager.networkManager().setUserAgent(userAgent); + setUserAgent(userAgent: string) { + this._state.userAgent = userAgent; + return this._delegate.setUserAgent(userAgent); } async _onBindingCalled(payload: string, context: js.ExecutionContext) { @@ -271,28 +284,24 @@ export class Page extends EventEmitter { return this.mainFrame().url(); } - async content(): Promise { - return await this.mainFrame().content(); + content(): Promise { + return this.mainFrame().content(); } - async setContent(html: string, options: { timeout?: number; waitUntil?: string | string[]; } | undefined) { - await this.mainFrame().setContent(html, options); + setContent(html: string, options?: frames.NavigateOptions): Promise { + return this.mainFrame().setContent(html, options); } - async goto(url: string, options: { referer?: string; timeout?: number; waitUntil?: string | string[]; } | undefined): Promise { - return await this.mainFrame().goto(url, options); + goto(url: string, options?: frames.GotoOptions): Promise { + return this.mainFrame().goto(url, options); } - async reload(options: { timeout?: number; waitUntil?: string | string[]; } = {}): Promise { - const [response] = await Promise.all([ - this.waitForNavigation(options), - this._client.send('Page.reload') - ]); - return response; + reload(options?: frames.NavigateOptions): Promise { + return this._delegate.reload(options); } - async waitForNavigation(options: { timeout?: number; waitUntil?: string | string[]; } = {}): Promise { - return await this.mainFrame().waitForNavigation(options); + waitForNavigation(options?: frames.NavigateOptions): Promise { + return this.mainFrame().waitForNavigation(options); } async waitForRequest(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise { @@ -321,24 +330,12 @@ export class Page extends EventEmitter { }, timeout, this._disconnectedPromise); } - async goBack(options: { timeout?: number; waitUntil?: string | string[]; } | undefined): Promise { - return this._go(-1, options); + goBack(options?: frames.NavigateOptions): Promise { + return this._delegate.goBack(options); } - async goForward(options: { timeout?: number; waitUntil?: string | string[]; } | undefined): Promise { - return this._go(+1, options); - } - - async _go(delta, options: { timeout?: number; waitUntil?: string | string[]; } | undefined): Promise { - const history = await this._client.send('Page.getNavigationHistory'); - const entry = history.entries[history.currentIndex + delta]; - if (!entry) - return null; - const [response] = await Promise.all([ - this.waitForNavigation(options), - this._client.send('Page.navigateToHistoryEntry', {entryId: entry.id}), - ]); - return response; + goForward(options?: frames.NavigateOptions): Promise { + return this._delegate.goForward(options); } async emulate(options: { viewport: types.Viewport; userAgent: string; }) { @@ -349,52 +346,41 @@ export class Page extends EventEmitter { } async setJavaScriptEnabled(enabled: boolean) { - if (this._javascriptEnabled === enabled) + if (this._state.javascriptEnabled === enabled) return; - this._javascriptEnabled = enabled; - await this._client.send('Emulation.setScriptExecutionDisabled', { value: !enabled }); + this._state.javascriptEnabled = enabled; + await this._delegate.setJavaScriptEnabled(enabled); } async setBypassCSP(enabled: boolean) { - await this._client.send('Page.setBypassCSP', { enabled }); + if (this._state.bypassCSP === enabled) + return; + await this._delegate.setBypassCSP(enabled); } - async emulateMedia(options: { - type?: string, - colorScheme?: 'dark' | 'light' | 'no-preference' }) { - assert(!options.type || mediaTypes.has(options.type), 'Unsupported media type: ' + options.type); - assert(!options.colorScheme || mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme); - const media = typeof options.type === 'undefined' ? this._emulatedMediaType : options.type; - const features = typeof options.colorScheme === 'undefined' ? [] : [{ name: 'prefers-color-scheme', value: options.colorScheme }]; - await this._client.send('Emulation.setEmulatedMedia', { media: media || '', features }); - this._emulatedMediaType = options.type; + async emulateMedia(options: { type?: input.MediaType, colorScheme?: input.MediaColorScheme }) { + assert(!options.type || input.mediaTypes.has(options.type), 'Unsupported media type: ' + options.type); + assert(!options.colorScheme || input.mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme); + if (options.type !== undefined) + this._state.mediaType = options.type; + if (options.colorScheme !== undefined) + this._state.mediaColorScheme = options.colorScheme; + await this._delegate.setEmulateMedia(this._state.mediaType, this._state.mediaColorScheme); } async setViewport(viewport: types.Viewport) { - const { - width, - height, - isMobile = false, - deviceScaleFactor = 1, - hasTouch = false, - isLandscape = false, - } = viewport; - const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' }; - await Promise.all([ - this._client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation }), - this._client.send('Emulation.setTouchEmulationEnabled', { - enabled: hasTouch - }) - ]); - const oldIsMobile = this._viewport ? !!this._viewport.isMobile : false; - const oldHasTouch = this._viewport ? !!this._viewport.hasTouch : false; - this._viewport = viewport; - if (oldIsMobile !== isMobile || oldHasTouch !== hasTouch) + const oldIsMobile = this._state.viewport ? !!this._state.viewport.isMobile : false; + const oldHasTouch = this._state.viewport ? !!this._state.viewport.hasTouch : false; + const newIsMobile = !!viewport.isMobile; + const newHasTouch = !!viewport.hasTouch; + this._state.viewport = { ...viewport }; + await this._delegate.setViewport(viewport); + if (oldIsMobile !== newIsMobile || oldHasTouch !== newHasTouch) await this.reload(); } viewport(): types.Viewport | null { - return this._viewport; + return this._state.viewport; } evaluate: types.Evaluate = (pageFunction, ...args) => { @@ -403,45 +389,45 @@ export class Page extends EventEmitter { async evaluateOnNewDocument(pageFunction: Function | string, ...args: any[]) { const source = helper.evaluationString(pageFunction, ...args); - await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source }); + await this._delegate.evaluateOnNewDocument(source); } async setCacheEnabled(enabled: boolean = true) { - await this._frameManager.networkManager().setCacheEnabled(enabled); + if (this._state.cacheEnabled === enabled) + return; + this._state.cacheEnabled = enabled; + await this._delegate.setCacheEnabled(enabled); } screenshot(options?: types.ScreenshotOptions): Promise { return this._screenshotter.screenshotPage(options); } - async title(): Promise { + title(): Promise { return this.mainFrame().title(); } async close(options: { runBeforeUnload: (boolean | undefined); } = {runBeforeUnload: undefined}) { assert(!this._disconnected, 'Protocol error: Connection closed. Most likely the page has been closed.'); const runBeforeUnload = !!options.runBeforeUnload; - if (runBeforeUnload) { - await this._client.send('Page.close'); - } else { - await this.browser()._closePage(this); + await this._delegate.closePage(runBeforeUnload); + if (!runBeforeUnload) await this._closedPromise; - } } isClosed(): boolean { return this._closed; } - click(selector: string | types.Selector, options?: ClickOptions) { + click(selector: string | types.Selector, options?: input.ClickOptions) { return this.mainFrame().click(selector, options); } - dblclick(selector: string | types.Selector, options?: MultiClickOptions) { + dblclick(selector: string | types.Selector, options?: input.MultiClickOptions) { return this.mainFrame().dblclick(selector, options); } - tripleclick(selector: string | types.Selector, options?: MultiClickOptions) { + tripleclick(selector: string | types.Selector, options?: input.MultiClickOptions) { return this.mainFrame().tripleclick(selector, options); } @@ -453,11 +439,11 @@ export class Page extends EventEmitter { return this.mainFrame().focus(selector); } - hover(selector: string | types.Selector, options?: PointerActionOptions) { + hover(selector: string | types.Selector, options?: input.PointerActionOptions) { return this.mainFrame().hover(selector, options); } - select(selector: string | types.Selector, ...values: (string | dom.ElementHandle | SelectOption)[]): Promise { + select(selector: string | types.Selector, ...values: (string | dom.ElementHandle | input.SelectOption)[]): Promise { return this.mainFrame().select(selector, ...values); } @@ -481,8 +467,3 @@ export class Page extends EventEmitter { return this.mainFrame().waitForFunction(pageFunction, options, ...args); } } - -type FileChooser = { - element: dom.ElementHandle, - multiple: boolean -}; diff --git a/src/webkit/Page.ts b/src/webkit/Page.ts index 13c0f6e5c2..33c36119bb 100644 --- a/src/webkit/Page.ts +++ b/src/webkit/Page.ts @@ -263,8 +263,8 @@ export class Page extends EventEmitter { } async emulateMedia(options: { - type?: string | null, - colorScheme?: 'dark' | 'light' | 'no-preference' | null }) { + type?: input.MediaType | null, + colorScheme?: input.MediaColorScheme | null }) { assert(!options.type || mediaTypes.has(options.type), 'Unsupported media type: ' + options.type); assert(!options.colorScheme || mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme); assert(!options.colorScheme, 'Media feature emulation is not supported');