diff --git a/src/client/page.ts b/src/client/page.ts index 334af00c762c6..e841b9b4260f4 100644 --- a/src/client/page.ts +++ b/src/client/page.ts @@ -639,6 +639,12 @@ export class Page extends ChannelOwner { + await this._channel.pause(); + }); + } + async _pdf(options: PDFOptions = {}): Promise { return this._wrapApiCall('page.pdf', async () => { const transportOptions: channels.PagePdfParams = { ...options } as channels.PagePdfParams; diff --git a/src/dispatchers/pageDispatcher.ts b/src/dispatchers/pageDispatcher.ts index 0cfbc0851ef90..2f43389205674 100644 --- a/src/dispatchers/pageDispatcher.ts +++ b/src/dispatchers/pageDispatcher.ts @@ -237,6 +237,10 @@ export class PageDispatcher extends Dispatcher i return { entries: await coverage.stopCSSCoverage() }; } + async pause() { + await this._page.pause(); + } + _onFrameAttached(frame: Frame) { this._dispatchEvent('frameAttached', { frame: FrameDispatcher.from(this._scope, frame) }); } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 53b4c235a6bc0..3cec93093e3df 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -770,6 +770,7 @@ export interface PageChannel extends Channel { mouseClick(params: PageMouseClickParams, metadata?: Metadata): Promise; touchscreenTap(params: PageTouchscreenTapParams, metadata?: Metadata): Promise; accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: Metadata): Promise; + pause(params?: PagePauseParams, metadata?: Metadata): Promise; pdf(params: PagePdfParams, metadata?: Metadata): Promise; crStartJSCoverage(params: PageCrStartJSCoverageParams, metadata?: Metadata): Promise; crStopJSCoverage(params?: PageCrStopJSCoverageParams, metadata?: Metadata): Promise; @@ -1066,6 +1067,9 @@ export type PageAccessibilitySnapshotOptions = { export type PageAccessibilitySnapshotResult = { rootAXNode?: AXNode, }; +export type PagePauseParams = {}; +export type PagePauseOptions = {}; +export type PagePauseResult = void; export type PagePdfParams = { scale?: number, displayHeaderFooter?: boolean, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index c02547027749c..262fb3d81ae79 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -842,6 +842,8 @@ Page: returns: rootAXNode: AXNode? + pause: + pdf: parameters: scale: number? diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index dcc408744a81f..678ef70a6c5a9 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -443,6 +443,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { interestingOnly: tOptional(tBoolean), root: tOptional(tChannel('ElementHandle')), }); + scheme.PagePauseParams = tOptional(tObject({})); scheme.PagePdfParams = tObject({ scale: tOptional(tNumber), displayHeaderFooter: tOptional(tBoolean), diff --git a/src/server/frames.ts b/src/server/frames.ts index b522ade64b45a..94871b8814b49 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -32,7 +32,10 @@ type ContextData = { contextPromise: Promise; contextResolveCallback: (c: dom.FrameExecutionContext) => void; context: dom.FrameExecutionContext | null; - rerunnableTasks: Set; + rerunnableTasks: Set<{ + rerun(context: dom.FrameExecutionContext): Promise; + terminate(error: Error): void; + }>; }; type DocumentInfo = { @@ -1046,6 +1049,24 @@ export class Frame extends EventEmitter { this._parentFrame = null; } + async evaluateSurvivingNavigations(callback: (context: dom.FrameExecutionContext) => Promise, world: types.World) { + return new Promise((resolve, terminate) => { + const data = this._contextData.get(world)!; + const task = { + terminate, + async rerun(context: dom.FrameExecutionContext) { + try { + resolve(await callback(context)); + data.rerunnableTasks.delete(task); + } catch (e) {} + } + }; + data.rerunnableTasks.add(task); + if (data.context) + task.rerun(data.context); + }); + } + private _scheduleRerunnableTask(progress: Progress, world: types.World, task: dom.SchedulableTask): Promise { const data = this._contextData.get(world)!; const rerunnableTask = new RerunnableTask(data, progress, task, true /* returnByValue */); diff --git a/src/server/page.ts b/src/server/page.ts index 5296e35d62360..0a7c190bbfbe2 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -492,6 +492,31 @@ export class Page extends EventEmitter { const identifier = PageBinding.identifier(name, world); return this._pageBindings.get(identifier) || this._browserContext._pageBindings.get(identifier); } + + async pause() { + if (!this._browserContext._browser._options.headful) + throw new Error('Cannot pause in headless mode.'); + await this.mainFrame().evaluateSurvivingNavigations(async context => { + await context.evaluateInternal(async () => { + const element = document.createElement('playwright-resume'); + element.style.position = 'absolute'; + element.style.top = '10px'; + element.style.left = '10px'; + element.style.zIndex = '2147483646'; + element.style.opacity = '0.9'; + element.setAttribute('role', 'button'); + element.tabIndex = 0; + element.style.fontSize = '50px'; + element.textContent = '▶️'; + element.title = 'Resume script'; + document.body.appendChild(element); + await new Promise(x => { + element.onclick = x; + }); + element.remove(); + }); + }, 'utility'); + } } export class Worker extends EventEmitter { diff --git a/test/pause.spec.ts b/test/pause.spec.ts new file mode 100644 index 0000000000000..2b041e5f816f3 --- /dev/null +++ b/test/pause.spec.ts @@ -0,0 +1,56 @@ +/** + * 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 { folio } from './fixtures'; +const extended = folio.extend(); +extended.browserOptions.override(({browserOptions}, runTest) => { + return runTest({ + ...browserOptions, + headless: false, + }); +}); +const {it, expect } = extended.build(); +it('should pause and resume the script', async ({page}) => { + let resolved = false; + const resumePromise = (page as any)._pause().then(() => resolved = true); + await new Promise(x => setTimeout(x, 0)); + expect(resolved).toBe(false); + await page.click('playwright-resume'); + await resumePromise; + expect(resolved).toBe(true); +}); + +it('should pause through a navigation', async ({page, server}) => { + let resolved = false; + const resumePromise = (page as any)._pause().then(() => resolved = true); + await new Promise(x => setTimeout(x, 0)); + expect(resolved).toBe(false); + await page.goto(server.EMPTY_PAGE); + await page.click('playwright-resume'); + await resumePromise; + expect(resolved).toBe(true); +}); + +it('should pause after a navigation', async ({page, server}) => { + await page.goto(server.EMPTY_PAGE); + + let resolved = false; + const resumePromise = (page as any)._pause().then(() => resolved = true); + await new Promise(x => setTimeout(x, 0)); + expect(resolved).toBe(false); + await page.click('playwright-resume'); + await resumePromise; + expect(resolved).toBe(true); +});