From 12ecd476dd485cdb662f51990c9494dcde30ae51 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 23 Sep 2024 14:43:28 +0200 Subject: [PATCH 01/29] fix(watch): cancel waitForCommand when files change (#32761) Fixes https://github.com/microsoft/playwright/issues/32758 --- packages/playwright/src/runner/watchMode.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index a47ca0fe32..2a2c9802f1 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -144,11 +144,13 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp else printPrompt(); + const waitForCommand = readCommand(); const command = await Promise.race([ onDirtyTests, - readCommand(), + waitForCommand.result, ]); - + if (command === 'changed') + waitForCommand.cancel(); if (bufferMode && command === 'changed') continue; @@ -282,7 +284,7 @@ async function runTests(watchOptions: WatchModeOptions, testServerConnection: Te }); } -function readCommand(): ManualPromise { +function readCommand(): { result: Promise, cancel: () => void } { const result = new ManualPromise(); const rl = readline.createInterface({ input: process.stdin, escapeCodeTimeout: 50 }); readline.emitKeypressEvents(process.stdin, rl); @@ -334,13 +336,14 @@ Change settings }; process.stdin.on('keypress', handler); - void result.finally(() => { + const cancel = () => { process.stdin.off('keypress', handler); rl.close(); if (process.stdin.isTTY) process.stdin.setRawMode(false); - }); - return result; + }; + void result.finally(cancel); + return { result, cancel }; } let showBrowserServer: PlaywrightServer | undefined; From 99895005e2a701eba922c4642ea6ac036245ef4b Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Mon, 23 Sep 2024 05:47:44 -0700 Subject: [PATCH 02/29] feat(chromium-tip-of-tree): roll to r1262 (#32760) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 5fd2927700..2012fc1c9e 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1261", + "revision": "1262", "installByDefault": false, - "browserVersion": "131.0.6726.0" + "browserVersion": "131.0.6734.0" }, { "name": "firefox", From 0cdc7ee1a3b392d9ab37618e2ee32bc1b929caa3 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 23 Sep 2024 08:42:18 -0700 Subject: [PATCH 03/29] chore: extract polling recorder (#32749) We are reusing recorder in a snapshot tab, no need for the polling harness to be there. --- .../injected/recorder/pollingRecorder.ts | 91 +++++++++++++++++++ .../src/server/injected/recorder/recorder.ts | 73 +-------------- .../src/server/recorder/DEPS.list | 2 +- .../src/server/recorder/contextRecorder.ts | 2 +- utils/generate_injected.js | 2 +- 5 files changed, 95 insertions(+), 75 deletions(-) create mode 100644 packages/playwright-core/src/server/injected/recorder/pollingRecorder.ts diff --git a/packages/playwright-core/src/server/injected/recorder/pollingRecorder.ts b/packages/playwright-core/src/server/injected/recorder/pollingRecorder.ts new file mode 100644 index 0000000000..57627f3723 --- /dev/null +++ b/packages/playwright-core/src/server/injected/recorder/pollingRecorder.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Mode, OverlayState, UIState } from '@recorder/recorderTypes'; +import type * as actions from '../../recorder/recorderActions'; +import type { InjectedScript } from '../injectedScript'; +import { Recorder } from './recorder'; +import type { RecorderDelegate } from './recorder'; + +interface Embedder { + __pw_recorderPerformAction(action: actions.PerformOnRecordAction): Promise; + __pw_recorderRecordAction(action: actions.Action): Promise; + __pw_recorderState(): Promise; + __pw_recorderSetSelector(selector: string): Promise; + __pw_recorderSetMode(mode: Mode): Promise; + __pw_recorderSetOverlayState(state: OverlayState): Promise; + __pw_refreshOverlay(): void; +} + +export class PollingRecorder implements RecorderDelegate { + private _recorder: Recorder; + private _embedder: Embedder; + private _pollRecorderModeTimer: number | undefined; + + constructor(injectedScript: InjectedScript) { + this._recorder = new Recorder(injectedScript); + this._embedder = injectedScript.window as any; + + injectedScript.onGlobalListenersRemoved.add(() => this._recorder.installListeners()); + + const refreshOverlay = () => { + this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console + }; + this._embedder.__pw_refreshOverlay = refreshOverlay; + refreshOverlay(); + } + + private async _pollRecorderMode() { + const pollPeriod = 1000; + if (this._pollRecorderModeTimer) + clearTimeout(this._pollRecorderModeTimer); + const state = await this._embedder.__pw_recorderState().catch(() => {}); + if (!state) { + this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod); + return; + } + const win = this._recorder.document.defaultView!; + if (win.top !== win) { + // Only show action point in the main frame, since it is relative to the page's viewport. + // Otherwise we'll see multiple action points at different locations. + state.actionPoint = undefined; + } + this._recorder.setUIState(state, this); + this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod); + } + + async performAction(action: actions.PerformOnRecordAction) { + await this._embedder.__pw_recorderPerformAction(action); + } + + async recordAction(action: actions.Action): Promise { + await this._embedder.__pw_recorderRecordAction(action); + } + + async setSelector(selector: string): Promise { + await this._embedder.__pw_recorderSetSelector(selector); + } + + async setMode(mode: Mode): Promise { + await this._embedder.__pw_recorderSetMode(mode); + } + + async setOverlayState(state: OverlayState): Promise { + await this._embedder.__pw_recorderSetOverlayState(state); + } +} + +export default PollingRecorder; diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 76d5791b64..c255a14b08 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -21,9 +21,8 @@ import type { Mode, OverlayState, UIState } from '@recorder/recorderTypes'; import type { ElementText } from '../selectorUtils'; import type { Highlight, HighlightOptions } from '../highlight'; import clipPaths from './clipPaths'; -import type { SimpleDomNode } from '../simpleDom'; -interface RecorderDelegate { +export interface RecorderDelegate { performAction?(action: actions.PerformOnRecordAction): Promise; recordAction?(action: actions.Action): Promise; setSelector?(selector: string): Promise; @@ -1457,73 +1456,3 @@ function createSvgElement(doc: Document, { tagName, attrs, children }: SvgJson): return elem; } - -interface Embedder { - __pw_recorderPerformAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise; - __pw_recorderRecordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise; - __pw_recorderState(): Promise; - __pw_recorderSetSelector(selector: string): Promise; - __pw_recorderSetMode(mode: Mode): Promise; - __pw_recorderSetOverlayState(state: OverlayState): Promise; - __pw_refreshOverlay(): void; -} - -export class PollingRecorder implements RecorderDelegate { - private _recorder: Recorder; - private _embedder: Embedder; - private _pollRecorderModeTimer: number | undefined; - - constructor(injectedScript: InjectedScript) { - this._recorder = new Recorder(injectedScript); - this._embedder = injectedScript.window as any; - - injectedScript.onGlobalListenersRemoved.add(() => this._recorder.installListeners()); - - const refreshOverlay = () => { - this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console - }; - this._embedder.__pw_refreshOverlay = refreshOverlay; - refreshOverlay(); - } - - private async _pollRecorderMode() { - const pollPeriod = 1000; - if (this._pollRecorderModeTimer) - clearTimeout(this._pollRecorderModeTimer); - const state = await this._embedder.__pw_recorderState().catch(() => {}); - if (!state) { - this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod); - return; - } - const win = this._recorder.document.defaultView!; - if (win.top !== win) { - // Only show action point in the main frame, since it is relative to the page's viewport. - // Otherwise we'll see multiple action points at different locations. - state.actionPoint = undefined; - } - this._recorder.setUIState(state, this); - this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod); - } - - async performAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) { - await this._embedder.__pw_recorderPerformAction(action, simpleDomNode); - } - - async recordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise { - await this._embedder.__pw_recorderRecordAction(action, simpleDomNode); - } - - async setSelector(selector: string): Promise { - await this._embedder.__pw_recorderSetSelector(selector); - } - - async setMode(mode: Mode): Promise { - await this._embedder.__pw_recorderSetMode(mode); - } - - async setOverlayState(state: OverlayState): Promise { - await this._embedder.__pw_recorderSetOverlayState(state); - } -} - -export default PollingRecorder; diff --git a/packages/playwright-core/src/server/recorder/DEPS.list b/packages/playwright-core/src/server/recorder/DEPS.list index f3bbfc23bf..85ae7c9152 100644 --- a/packages/playwright-core/src/server/recorder/DEPS.list +++ b/packages/playwright-core/src/server/recorder/DEPS.list @@ -5,7 +5,7 @@ ../isomorphic/** ../registry/** ../../common/ -../../generated/recorderSource.ts +../../generated/pollingRecorderSource.ts ../../protocol/ ../../utils/** ../../utilsBundle.ts diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index dc38866167..694393b860 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -17,7 +17,7 @@ import type * as channels from '@protocol/channels'; import type { Source } from '@recorder/recorderTypes'; import { EventEmitter } from 'events'; -import * as recorderSource from '../../generated/recorderSource'; +import * as recorderSource from '../../generated/pollingRecorderSource'; import { eventsHelper, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils'; import { raceAgainstDeadline } from '../../utils/timeoutRunner'; import { BrowserContext } from '../browserContext'; diff --git a/utils/generate_injected.js b/utils/generate_injected.js index cb56ce2db5..bffef1cfaa 100644 --- a/utils/generate_injected.js +++ b/utils/generate_injected.js @@ -45,7 +45,7 @@ const injectedScripts = [ true, ], [ - path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'recorder', 'recorder.ts'), + path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'recorder', 'pollingRecorder.ts'), path.join(ROOT, 'packages', 'playwright-core', 'lib', 'server', 'injected', 'packed'), path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), true, From 281eff120926203229468309452a0df2e3a7e245 Mon Sep 17 00:00:00 2001 From: Mathias Leppich Date: Mon, 23 Sep 2024 22:17:47 +0200 Subject: [PATCH 04/29] docs(trial): note that modifier keys are pressed regardless of trial option (#32734) --- docs/src/api/class-frame.md | 8 ++-- docs/src/api/class-locator.md | 8 ++-- docs/src/api/class-page.md | 8 ++-- docs/src/api/params.md | 5 +++ packages/playwright-core/types/types.d.ts | 48 +++++++++++++++++------ 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index f3f308622f..bf3666b9be 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -280,7 +280,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Frame.click.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Frame.click.trial = %%-input-trial-%% +### option: Frame.click.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Frame.content @@ -341,7 +341,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Frame.dblclick.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Frame.dblclick.trial = %%-input-trial-%% +### option: Frame.dblclick.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Frame.dispatchEvent @@ -1153,7 +1153,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Frame.hover.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Frame.hover.trial = %%-input-trial-%% +### option: Frame.hover.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ### option: Frame.hover.noWaitAfter = %%-input-no-wait-after-removed-%% @@ -1703,7 +1703,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Frame.tap.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Frame.tap.trial = %%-input-trial-%% +### option: Frame.tap.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Frame.textContent diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 88658b5494..86de53841c 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -433,7 +433,7 @@ await page.Locator("canvas").ClickAsync(new() { ### option: Locator.click.timeout = %%-input-timeout-js-%% * since: v1.14 -### option: Locator.click.trial = %%-input-trial-%% +### option: Locator.click.trial = %%-input-trial-with-modifiers-%% * since: v1.14 ## async method: Locator.count @@ -516,7 +516,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.dblclick.timeout = %%-input-timeout-js-%% * since: v1.14 -### option: Locator.dblclick.trial = %%-input-trial-%% +### option: Locator.dblclick.trial = %%-input-trial-with-modifiers-%% * since: v1.14 ## async method: Locator.dispatchEvent @@ -1266,7 +1266,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.hover.timeout = %%-input-timeout-js-%% * since: v1.14 -### option: Locator.hover.trial = %%-input-trial-%% +### option: Locator.hover.trial = %%-input-trial-with-modifiers-%% * since: v1.14 ### option: Locator.hover.noWaitAfter = %%-input-no-wait-after-removed-%% @@ -2331,7 +2331,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.tap.timeout = %%-input-timeout-js-%% * since: v1.14 -### option: Locator.tap.trial = %%-input-trial-%% +### option: Locator.tap.trial = %%-input-trial-with-modifiers-%% * since: v1.14 ## async method: Locator.textContent diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index c0890d04ff..b437b9313e 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -812,7 +812,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Page.click.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Page.click.trial = %%-input-trial-%% +### option: Page.click.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Page.close @@ -915,7 +915,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Page.dblclick.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Page.dblclick.trial = %%-input-trial-%% +### option: Page.dblclick.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Page.dispatchEvent @@ -2437,7 +2437,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Page.hover.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Page.hover.trial = %%-input-trial-%% +### option: Page.hover.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ### option: Page.hover.noWaitAfter = %%-input-no-wait-after-removed-%% @@ -4099,7 +4099,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Page.tap.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Page.tap.trial = %%-input-trial-%% +### option: Page.tap.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Page.textContent diff --git a/docs/src/api/params.md b/docs/src/api/params.md index de930ee97e..4a0e3af657 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -136,6 +136,11 @@ defaults to 1. See [UIEvent.detail]. When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. +## input-trial-with-modifiers +- `trial` <[boolean]> + +When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys are pressed. + ## input-source-position - `sourcePosition` <[Object]> - `x` <[float]> diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index d6d4e7f39b..e0c640db6f 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2120,7 +2120,9 @@ export interface Page { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -2233,7 +2235,9 @@ export interface Page { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -3125,7 +3129,9 @@ export interface Page { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -4266,7 +4272,9 @@ export interface Page { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -5845,7 +5853,9 @@ export interface Frame { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -5931,7 +5941,9 @@ export interface Frame { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -6621,7 +6633,9 @@ export interface Frame { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -7308,7 +7322,9 @@ export interface Frame { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -12045,7 +12061,9 @@ export interface Locator { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -12155,7 +12173,9 @@ export interface Locator { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -12825,7 +12845,9 @@ export interface Locator { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -13626,7 +13648,9 @@ export interface Locator { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; From 26dc8955d3259a598038d5da2e8b5647ee4cde32 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 23 Sep 2024 22:22:57 +0200 Subject: [PATCH 05/29] docs: explain glob patterns (#32728) --- docs/src/network.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/src/network.md b/docs/src/network.md index 152231556e..4d6f229678 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -700,6 +700,27 @@ await Page.RouteAsync("**/title.html", async route => }); ``` +## Glob URL patterns + +Playwright uses simplified glob patterns for URL matching in network interception methods like [`method: Page.route`] or [`method: Page.waitForResponse`]. These patterns support basic wildcards: + +1. Asterisks: + - A single `*` matches any characters except `/` + - A double `**` matches any characters including `/` +1. Question mark `?` matches any single character except `/` +1. Curly braces `{}` can be used to match a list of options separated by commas `,` + +Examples: +- `https://example.com/*.js` matches `https://example.com/file.js` but not `https://example.com/path/file.js` +- `**/*.js` matches both `https://example.com/file.js` and `https://example.com/path/file.js` +- `**/*.{png,jpg,jpeg}` matches all image requests + +Important notes: + +- The glob pattern must match the entire URL, not just a part of it. +- When using globs for URL matching, consider the full URL structure, including the protocol and path separators. +- For more complex matching requirements, consider using [RegExp] instead of glob patterns. + ## WebSockets Playwright supports [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) inspection out of the box. Every time a WebSocket is created, the [`event: Page.webSocket`] event is fired. This event contains the [WebSocket] instance for further web socket frames inspection: From c9a26e60f507c593a4c22d87021889323b078e66 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 23 Sep 2024 14:30:40 -0700 Subject: [PATCH 06/29] fix(webkit): 204 response is not a failure (#32768) The login being changed was added in https://github.com/microsoft/playwright/pull/1260 and is supposed to only work for navigation requests. Reference: https://github.com/microsoft/playwright/issues/32752 --- .../playwright-core/src/server/webkit/wkPage.ts | 2 +- tests/page/page-event-request.spec.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 2f579b619b..f432f85459 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -1116,7 +1116,7 @@ export class WKPage implements PageDelegate { const response = request.createResponse(event.response); this._page._frameManager.requestReceivedResponse(response); - if (response.status() === 204) { + if (response.status() === 204 && request.request.isNavigationRequest()) { this._onLoadingFailed(session, { requestId: event.requestId, errorText: 'Aborted: 204 No Content', diff --git a/tests/page/page-event-request.spec.ts b/tests/page/page-event-request.spec.ts index cfc2f7a1e2..f32f224374 100644 --- a/tests/page/page-event-request.spec.ts +++ b/tests/page/page-event-request.spec.ts @@ -241,3 +241,20 @@ it('main resource xhr should have type xhr', async ({ page, server }) => { expect(request.isNavigationRequest()).toBe(false); expect(request.resourceType()).toBe('xhr'); }); + +it('should finish 204 request', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32752' } +}, async ({ page, server, browserName }) => { + it.fixme(browserName === 'chromium'); + server.setRoute('/204', (req, res) => { + res.writeHead(204, { 'Content-type': 'text/plain' }); + res.end(); + }); + await page.goto(server.EMPTY_PAGE); + const reqPromise = Promise.race([ + page.waitForEvent('requestfailed', r => r.url().endsWith('/204')).then(() => 'requestfailed'), + page.waitForEvent('requestfinished', r => r.url().endsWith('/204')).then(() => 'requestfinished'), + ]); + page.evaluate(async url => { await fetch(url); }, server.PREFIX + '/204').catch(() => {}); + expect(await reqPromise).toBe('requestfinished'); +}); From 0ee9a8292647947912594705c5c6198b12f54064 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 23 Sep 2024 23:31:04 +0200 Subject: [PATCH 07/29] test: skip 'should work with error after successful open' on WebKit Windows (#32769) --- tests/library/route-web-socket.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/library/route-web-socket.spec.ts b/tests/library/route-web-socket.spec.ts index 207d581919..db509a69f2 100644 --- a/tests/library/route-web-socket.spec.ts +++ b/tests/library/route-web-socket.spec.ts @@ -144,9 +144,10 @@ for (const mock of ['no-mock', 'no-match', 'pass-through']) { ]); }); - test('should work with error after successful open', async ({ page, server, browserName, isLinux }) => { + test('should work with error after successful open', async ({ page, server, browserName, isLinux, isWindows }) => { test.skip(browserName === 'firefox', 'Firefox does not close the websocket upon a bad frame'); test.skip(browserName === 'webkit' && isLinux, 'WebKit linux does not close the websocket upon a bad frame'); + test.skip(browserName === 'webkit' && isWindows, 'WebKit Windows does not close the websocket upon a bad frame'); const upgradePromise = server.waitForUpgrade(); await setupWS(page, server.PORT, 'blob'); From 11320d34c64706e42ab4ca41aef3cc84d05dc970 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 23 Sep 2024 15:48:11 -0700 Subject: [PATCH 08/29] Revert chore: ignore third-party execution contexts (#32437) (#32771) Partially revert #32437 and add a test that console.log() messages from content scripts are properly reported Fixes https://github.com/microsoft/playwright/issues/32762 --- .../src/server/chromium/crPage.ts | 7 +++---- packages/playwright-core/src/server/dom.ts | 4 ++-- .../src/server/firefox/ffPage.ts | 7 +++---- .../src/server/webkit/wkPage.ts | 7 +++---- .../extension-with-logging/background.js | 5 +++++ .../assets/extension-with-logging/content.js | 1 + .../extension-with-logging/manifest.json | 17 +++++++++++++++ tests/library/chromium/launcher.spec.ts | 21 +++++++++++++++++++ 8 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 tests/assets/extension-with-logging/background.js create mode 100644 tests/assets/extension-with-logging/content.js create mode 100644 tests/assets/extension-with-logging/manifest.json diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index fbdc9db91a..bba14ff00e 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -694,16 +694,15 @@ class FrameSession { if (!frame || this._eventBelongsToStaleFrame(frame._id)) return; const delegate = new CRExecutionContext(this._client, contextPayload); - let worldName: types.World; + let worldName: types.World|null = null; if (contextPayload.auxData && !!contextPayload.auxData.isDefault) worldName = 'main'; else if (contextPayload.name === UTILITY_WORLD_NAME) worldName = 'utility'; - else - return; const context = new dom.FrameExecutionContext(delegate, frame, worldName); (context as any)[contextDelegateSymbol] = delegate; - frame._contextCreated(worldName, context); + if (worldName) + frame._contextCreated(worldName, context); this._contextIdToContext.set(contextPayload.id, context); } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 55105bd50c..05a8b4fda2 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -50,9 +50,9 @@ export function isNonRecoverableDOMError(error: Error) { export class FrameExecutionContext extends js.ExecutionContext { readonly frame: frames.Frame; private _injectedScriptPromise?: Promise; - readonly world: types.World; + readonly world: types.World | null; - constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame, world: types.World) { + constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame, world: types.World|null) { super(frame, delegate, world || 'content-script'); this.frame = frame; this.world = world; diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 03a27954dd..9dfffc1eb3 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -163,16 +163,15 @@ export class FFPage implements PageDelegate { if (!frame) return; const delegate = new FFExecutionContext(this._session, executionContextId); - let worldName: types.World; + let worldName: types.World|null = null; if (auxData.name === UTILITY_WORLD_NAME) worldName = 'utility'; else if (!auxData.name) worldName = 'main'; - else - return; const context = new dom.FrameExecutionContext(delegate, frame, worldName); (context as any)[contextDelegateSymbol] = delegate; - frame._contextCreated(worldName, context); + if (worldName) + frame._contextCreated(worldName, context); this._contextIdToContext.set(executionContextId, context); } diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index f432f85459..320df04ce2 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -502,16 +502,15 @@ export class WKPage implements PageDelegate { if (!frame) return; const delegate = new WKExecutionContext(this._session, contextPayload.id); - let worldName: types.World; + let worldName: types.World|null = null; if (contextPayload.type === 'normal') worldName = 'main'; else if (contextPayload.type === 'user' && contextPayload.name === UTILITY_WORLD_NAME) worldName = 'utility'; - else - return; const context = new dom.FrameExecutionContext(delegate, frame, worldName); (context as any)[contextDelegateSymbol] = delegate; - frame._contextCreated(worldName, context); + if (worldName) + frame._contextCreated(worldName, context); this._contextIdToContext.set(contextPayload.id, context); } diff --git a/tests/assets/extension-with-logging/background.js b/tests/assets/extension-with-logging/background.js new file mode 100644 index 0000000000..3780fa3f21 --- /dev/null +++ b/tests/assets/extension-with-logging/background.js @@ -0,0 +1,5 @@ +console.log("Service worker script loaded"); + +chrome.runtime.onInstalled.addListener(() => { + console.log("Extension installed"); +}); \ No newline at end of file diff --git a/tests/assets/extension-with-logging/content.js b/tests/assets/extension-with-logging/content.js new file mode 100644 index 0000000000..e718206c2a --- /dev/null +++ b/tests/assets/extension-with-logging/content.js @@ -0,0 +1 @@ +console.log("Test console log from a third-party execution context"); diff --git a/tests/assets/extension-with-logging/manifest.json b/tests/assets/extension-with-logging/manifest.json new file mode 100644 index 0000000000..429dc77980 --- /dev/null +++ b/tests/assets/extension-with-logging/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "Console Log Extension", + "version": "1.0", + "background": { + "service_worker": "background.js" + }, + "permissions": [ + "tabs" + ], + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"] + } + ] + } \ No newline at end of file diff --git a/tests/library/chromium/launcher.spec.ts b/tests/library/chromium/launcher.spec.ts index eb0fc997ad..f36a089fa7 100644 --- a/tests/library/chromium/launcher.spec.ts +++ b/tests/library/chromium/launcher.spec.ts @@ -146,6 +146,27 @@ it('should support request/response events when using backgroundPage()', async ( await context.close(); }); +it('should report console messages from content script', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32762' } +}, async ({ browserType, createUserDataDir, asset, server }) => { + const userDataDir = await createUserDataDir(); + const extensionPath = asset('extension-with-logging'); + const extensionOptions = { + headless: false, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }; + const context = await browserType.launchPersistentContext(userDataDir, extensionOptions); + const page = await context.newPage(); + const consolePromise = page.waitForEvent('console', e => e.text().includes('Test console log from a third-party execution context')); + await page.goto(server.EMPTY_PAGE); + const message = await consolePromise; + expect(message.text()).toContain('Test console log from a third-party execution context'); + await context.close(); +}); + it('should not create pages automatically', async ({ browserType }) => { const browser = await browserType.launch(); const browserSession = await browser.newBrowserCDPSession(); From 0c8b2a7c32cf48e146e3294180d3afc006ed4d89 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 23 Sep 2024 15:51:15 -0700 Subject: [PATCH 09/29] chore: take snapshot tab apart (#32756) --- packages/trace-viewer/src/ui/snapshotTab.css | 5 +- packages/trace-viewer/src/ui/snapshotTab.tsx | 335 ++++++++++++------- packages/trace-viewer/src/ui/workbench.tsx | 4 +- tests/library/trace-viewer.spec.ts | 2 +- 4 files changed, 214 insertions(+), 132 deletions(-) diff --git a/packages/trace-viewer/src/ui/snapshotTab.css b/packages/trace-viewer/src/ui/snapshotTab.css index 2677cfe53a..926685dc81 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.css +++ b/packages/trace-viewer/src/ui/snapshotTab.css @@ -15,12 +15,8 @@ */ .snapshot-tab { - display: flex; - flex: auto; - flex-direction: column; align-items: stretch; outline: none; - --browser-frame-header-height: 40px; overflow: hidden; } @@ -73,6 +69,7 @@ margin: 1px; padding: 10px; position: relative; + --browser-frame-header-height: 40px; } .snapshot-container { diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 9dafa10b96..b845c60cd5 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -40,7 +40,7 @@ function findClosest(items: T[], metric: (v: T) => number, target: number) { }); } -export const SnapshotTab: React.FunctionComponent<{ +export const SnapshotTabsView: React.FunctionComponent<{ action: ActionTraceEvent | undefined, model?: MultiTraceModel, sdkLanguage: Language, @@ -50,63 +50,69 @@ export const SnapshotTab: React.FunctionComponent<{ highlightedLocator: string, setHighlightedLocator: (locator: string) => void, openPage?: (url: string, target?: string) => Window | any, -}> = ({ action, model, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator, openPage }) => { - const [measure, ref] = useMeasure(); +}> = ({ action, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator, openPage }) => { const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action'); const [showScreenshotInsteadOfSnapshot] = useSetting('screenshot-instead-of-snapshot', false); - type Snapshot = { action: ActionTraceEvent, snapshotName: string, point?: { x: number, y: number }, hasInputTarget?: boolean }; - const { snapshots } = React.useMemo(() => { - if (!action) - return { snapshots: {} }; - - // if the action has no beforeSnapshot, use the last available afterSnapshot. - let beforeSnapshot: Snapshot | undefined = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined; - let a = action; - while (!beforeSnapshot && a) { - a = prevInList(a); - beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined; - } - const afterSnapshot: Snapshot | undefined = action.afterSnapshot ? { action, snapshotName: action.afterSnapshot } : beforeSnapshot; - const actionSnapshot: Snapshot | undefined = action.inputSnapshot ? { action, snapshotName: action.inputSnapshot, hasInputTarget: true } : afterSnapshot; - if (actionSnapshot) - actionSnapshot.point = action.point; - return { snapshots: { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot } }; + const snapshots = React.useMemo(() => { + return collectSnapshots(action); }, [action]); - - const { snapshotInfoUrl, snapshotUrl, popoutUrl, point } = React.useMemo(() => { + const snapshotUrls = React.useMemo(() => { const snapshot = snapshots[snapshotTab]; - if (!snapshot) - return { snapshotUrl: kBlankSnapshotUrl }; - - const params = new URLSearchParams(); - params.set('trace', context(snapshot.action).traceUrl); - params.set('name', snapshot.snapshotName); - if (snapshot.point) { - params.set('pointX', String(snapshot.point.x)); - params.set('pointY', String(snapshot.point.y)); - if (snapshot.hasInputTarget) - params.set('hasInputTarget', '1'); - } - const snapshotUrl = new URL(`snapshot/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString(); - const snapshotInfoUrl = new URL(`snapshotInfo/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString(); - - const popoutParams = new URLSearchParams(); - popoutParams.set('r', snapshotUrl); - popoutParams.set('trace', context(snapshot.action).traceUrl); - if (snapshot.point) { - popoutParams.set('pointX', String(snapshot.point.x)); - popoutParams.set('pointY', String(snapshot.point.y)); - if (snapshot.hasInputTarget) - params.set('hasInputTarget', '1'); - } - const popoutUrl = new URL(`snapshot.html?${popoutParams.toString()}`, window.location.href).toString(); - return { snapshots, snapshotInfoUrl, snapshotUrl, popoutUrl, point: snapshot.point }; + return snapshot ? extendSnapshot(snapshot) : undefined; }, [snapshots, snapshotTab]); + return
+ + setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} /> + {['action', 'before', 'after'].map(tab => { + return setSnapshotTab(tab as 'action' | 'before' | 'after')} + >; + })} +
+ { + if (!openPage) + openPage = window.open; + const win = openPage(snapshotUrls?.popoutUrl || '', '_blank'); + win?.addEventListener('DOMContentLoaded', () => { + const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []); + new ConsoleAPI(injectedScript); + }); + }} /> +
+ {!showScreenshotInsteadOfSnapshot && } + {showScreenshotInsteadOfSnapshot && } +
; +}; + +export const SnapshotView: React.FunctionComponent<{ + snapshotUrls: SnapshotUrls | undefined, + sdkLanguage: Language, + testIdAttributeName: string, + isInspecting: boolean, + setIsInspecting: (isInspecting: boolean) => void, + highlightedLocator: string, + setHighlightedLocator: (locator: string) => void, +}> = ({ snapshotUrls, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator }) => { const iframeRef0 = React.useRef(null); const iframeRef1 = React.useRef(null); - const [snapshotInfo, setSnapshotInfo] = React.useState<{ viewport: typeof kDefaultViewport, url: string, timestamp?: number, wallTime?: undefined }>({ viewport: kDefaultViewport, url: '' }); + const [snapshotInfo, setSnapshotInfo] = React.useState({ viewport: kDefaultViewport, url: '' }); const loadingRef = React.useRef({ iteration: 0, visibleIframe: 0 }); React.useEffect(() => { @@ -115,17 +121,7 @@ export const SnapshotTab: React.FunctionComponent<{ const newVisibleIframe = 1 - loadingRef.current.visibleIframe; loadingRef.current.iteration = thisIteration; - const newSnapshotInfo = { url: '', viewport: kDefaultViewport, timestamp: undefined, wallTime: undefined }; - if (snapshotInfoUrl) { - const response = await fetch(snapshotInfoUrl); - const info = await response.json(); - if (!info.error) { - newSnapshotInfo.url = info.url; - newSnapshotInfo.viewport = info.viewport; - newSnapshotInfo.timestamp = info.timestamp; - newSnapshotInfo.wallTime = info.wallTime; - } - } + const newSnapshotInfo = await fetchSnapshotInfo(snapshotUrls?.snapshotInfoUrl); // Interrupted by another load - bail out. if (loadingRef.current.iteration !== thisIteration) @@ -140,6 +136,7 @@ export const SnapshotTab: React.FunctionComponent<{ iframe.addEventListener('error', loadedCallback); // Try preventing history entry from being created. + const snapshotUrl = snapshotUrls?.snapshotUrl || kBlankSnapshotUrl; if (iframe.contentWindow) iframe.contentWindow.location.replace(snapshotUrl); else @@ -159,33 +156,10 @@ export const SnapshotTab: React.FunctionComponent<{ loadingRef.current.visibleIframe = newVisibleIframe; setSnapshotInfo(newSnapshotInfo); })(); - }, [snapshotUrl, snapshotInfoUrl]); - - const windowHeaderHeight = 40; - const snapshotContainerSize = { - width: snapshotInfo.viewport.width, - height: snapshotInfo.viewport.height + windowHeaderHeight, - }; - const scale = Math.min(measure.width / snapshotContainerSize.width, measure.height / snapshotContainerSize.height, 1); - const translate = { - x: (measure.width - snapshotContainerSize.width) / 2, - y: (measure.height - snapshotContainerSize.height) / 2, - }; - - const page = action ? pageForAction(action) : undefined; - const screencastFrame = React.useMemo( - () => { - if (snapshotInfo.wallTime && page?.screencastFrames[0]?.frameSwapWallTime) - return findClosest(page.screencastFrames, frame => frame.frameSwapWallTime!, snapshotInfo.wallTime); - - if (snapshotInfo.timestamp && page?.screencastFrames) - return findClosest(page.screencastFrames, frame => frame.timestamp, snapshotInfo.timestamp); - }, - [page?.screencastFrames, snapshotInfo.timestamp, snapshotInfo.wallTime] - ); + }, [snapshotUrls]); return
{ if (event.key === 'Escape') { @@ -210,46 +184,72 @@ export const SnapshotTab: React.FunctionComponent<{ setHighlightedLocator={setHighlightedLocator} iframe={iframeRef1.current} iteration={loadingRef.current.iteration} /> - - setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} /> - {['action', 'before', 'after'].map(tab => { - return setSnapshotTab(tab as 'action' | 'before' | 'after')} - >; - })} -
- { - if (!openPage) - openPage = window.open; - const win = openPage(popoutUrl || '', '_blank'); - win?.addEventListener('DOMContentLoaded', () => { - const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []); - new ConsoleAPI(injectedScript); - }); - }}> -
-
-
- - {(showScreenshotInsteadOfSnapshot && screencastFrame) && ( - <> - {point && } - {`Screenshot ${renderTitle(snapshotTab)}`} src={`sha1/${screencastFrame.sha1}`} width={screencastFrame.width} height={screencastFrame.height} /> - - )} -
- - -
+ +
+ +
+
+
; +}; + +export const ScreenshotView: React.FunctionComponent<{ + action: ActionTraceEvent | undefined, + snapshotUrls: SnapshotUrls | undefined, + snapshot: Snapshot | undefined, +}> = ({ action, snapshotUrls, snapshot }) => { + const [snapshotInfo, setSnapshotInfo] = React.useState({ viewport: kDefaultViewport, url: '' }); + React.useEffect(() => { + fetchSnapshotInfo(snapshotUrls?.snapshotInfoUrl).then(setSnapshotInfo); + }, [snapshotUrls?.snapshotInfoUrl]); + + const page = action ? pageForAction(action) : undefined; + const screencastFrame = React.useMemo(() => { + if (snapshotInfo.wallTime && page?.screencastFrames[0]?.frameSwapWallTime) + return findClosest(page.screencastFrames, frame => frame.frameSwapWallTime!, snapshotInfo.wallTime); + + if (snapshotInfo.timestamp && page?.screencastFrames) + return findClosest(page.screencastFrames, frame => frame.timestamp, snapshotInfo.timestamp); + }, + [page?.screencastFrames, snapshotInfo.timestamp, snapshotInfo.wallTime]); + + const point = snapshot?.point; + + return + {screencastFrame && ( + <> + {point && } + {`Screenshot + + )} + ; +}; + +const SnapshotWrapper: React.FunctionComponent> = ({ snapshotInfo, children }) => { + const [measure, ref] = useMeasure(); + + const windowHeaderHeight = 40; + const snapshotContainerSize = { + width: snapshotInfo.viewport.width, + height: snapshotInfo.viewport.height + windowHeaderHeight, + }; + + const scale = Math.min(measure.width / snapshotContainerSize.width, measure.height / snapshotContainerSize.height, 1); + const translate = { + x: (measure.width - snapshotContainerSize.width) / 2, + y: (measure.height - snapshotContainerSize.height) / 2, + }; + + return
+
+ + {children}
; }; @@ -325,5 +325,90 @@ function createRecorders(recorders: { recorder: Recorder, frameSelector: string } } -const kDefaultViewport = { width: 1280, height: 720 }; +export type Snapshot = { + action: ActionTraceEvent; + snapshotName: string; + point?: { x: number, y: number }; + hasInputTarget?: boolean; +}; + +export type SnapshotInfo = { + url: string; + viewport: { width: number, height: number }; + timestamp?: number; + wallTime?: undefined; +}; + +export type Snapshots = { + action?: Snapshot; + before?: Snapshot; + after?: Snapshot; +}; + +export type SnapshotUrls = { + snapshotInfoUrl: string; + snapshotUrl: string; + popoutUrl: string; +}; + +export function collectSnapshots(action: ActionTraceEvent | undefined): Snapshots { + if (!action) + return {}; + + // if the action has no beforeSnapshot, use the last available afterSnapshot. + let beforeSnapshot: Snapshot | undefined = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined; + let a = action; + while (!beforeSnapshot && a) { + a = prevInList(a); + beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined; + } + const afterSnapshot: Snapshot | undefined = action.afterSnapshot ? { action, snapshotName: action.afterSnapshot } : beforeSnapshot; + const actionSnapshot: Snapshot | undefined = action.inputSnapshot ? { action, snapshotName: action.inputSnapshot, hasInputTarget: true } : afterSnapshot; + if (actionSnapshot) + actionSnapshot.point = action.point; + return { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot }; +} + +export function extendSnapshot(snapshot: Snapshot): SnapshotUrls { + const params = new URLSearchParams(); + params.set('trace', context(snapshot.action).traceUrl); + params.set('name', snapshot.snapshotName); + if (snapshot.point) { + params.set('pointX', String(snapshot.point.x)); + params.set('pointY', String(snapshot.point.y)); + if (snapshot.hasInputTarget) + params.set('hasInputTarget', '1'); + } + const snapshotUrl = new URL(`snapshot/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString(); + const snapshotInfoUrl = new URL(`snapshotInfo/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString(); + + const popoutParams = new URLSearchParams(); + popoutParams.set('r', snapshotUrl); + popoutParams.set('trace', context(snapshot.action).traceUrl); + if (snapshot.point) { + popoutParams.set('pointX', String(snapshot.point.x)); + popoutParams.set('pointY', String(snapshot.point.y)); + if (snapshot.hasInputTarget) + params.set('hasInputTarget', '1'); + } + const popoutUrl = new URL(`snapshot.html?${popoutParams.toString()}`, window.location.href).toString(); + return { snapshotInfoUrl, snapshotUrl, popoutUrl }; +} + +export async function fetchSnapshotInfo(snapshotInfoUrl: string | undefined) { + const result = { url: '', viewport: kDefaultViewport, timestamp: undefined, wallTime: undefined }; + if (snapshotInfoUrl) { + const response = await fetch(snapshotInfoUrl); + const info = await response.json(); + if (!info.error) { + result.url = info.url; + result.viewport = info.viewport; + result.timestamp = info.timestamp; + result.wallTime = info.wallTime; + } + } + return result; +} + +export const kDefaultViewport = { width: 1280, height: 720 }; const kBlankSnapshotUrl = 'data:text/html,'; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 95c18d8d0a..13c5f5fd0e 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -25,7 +25,7 @@ import type { ConsoleEntry } from './consoleTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab'; import type * as modelUtil from './modelUtil'; import { NetworkTab, useNetworkTabModel } from './networkTab'; -import { SnapshotTab } from './snapshotTab'; +import { SnapshotTabsView } from './snapshotTab'; import { SourceTab } from './sourceTab'; import { TabbedPane } from '@web/components/tabbedPane'; import type { TabbedPaneTabModel } from '@web/components/tabbedPane'; @@ -331,7 +331,7 @@ export const Workbench: React.FunctionComponent<{ orientation='horizontal' sidebarIsFirst settingName='actionListSidebar' - main={ Action`); + const screenshot = traceViewer.page.getByAltText(`Screenshot of page.goto`); const snapshot = (await traceViewer.snapshotFrame('page.goto')).owner(); await expect(snapshot).toBeVisible(); await expect(screenshot).not.toBeVisible(); From c7a5278fb3198e78d3bf0ca8e49c829bc0a65d9b Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 23 Sep 2024 15:51:27 -0700 Subject: [PATCH 10/29] fix: do not start tracing in default recorder (#32770) --- .../src/server/recorder/contextRecorder.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 694393b860..a02ea9f5b4 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -54,9 +54,11 @@ export class ContextRecorder extends EventEmitter { private _throttledOutputFile: ThrottledFile | null = null; private _orderedLanguages: LanguageGenerator[] = []; private _listeners: RegisteredListener[] = []; + private _codegenMode: 'actions' | 'trace-events'; constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, delegate: ContextRecorderDelegate) { super(); + this._codegenMode = codegenMode; this._context = context; this._params = params; this._delegate = delegate; @@ -145,10 +147,12 @@ export class ContextRecorder extends EventEmitter { setEnabled(enabled: boolean) { this._collection.setEnabled(enabled); - if (enabled) - this._context.tracing.startChunk({ name: 'trace', title: 'trace' }).catch(() => {}); - else - this._context.tracing.stopChunk({ mode: 'discard' }).catch(() => {}); + if (this._codegenMode === 'trace-events') { + if (enabled) + this._context.tracing.startChunk({ name: 'trace', title: 'trace' }).catch(() => {}); + else + this._context.tracing.stopChunk({ mode: 'discard' }).catch(() => {}); + } } dispose() { From 8557b98aeec1683143556e481aeed61567f4f530 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 24 Sep 2024 01:32:36 +0200 Subject: [PATCH 11/29] test: fix CR/LF warning on only-changed tests (#32772) --- tests/playwright-test/only-changed.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/playwright-test/only-changed.spec.ts b/tests/playwright-test/only-changed.spec.ts index 674234a240..b632ba262a 100644 --- a/tests/playwright-test/only-changed.spec.ts +++ b/tests/playwright-test/only-changed.spec.ts @@ -26,6 +26,7 @@ const test = baseTest.extend<{ git(command: string): void }>({ git(`init --initial-branch=main`); git(`config --local user.name "Robert Botman"`); git(`config --local user.email "botty@mcbotface.com"`); + git(`config --local core.autocrlf false`); await use((command: string) => git(command)); }, From fbeba6619a6ada0b255eb73b7fc0e76c614148ba Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 23 Sep 2024 17:55:30 -0700 Subject: [PATCH 12/29] devops(bidi): increase global timeout to 60m (#32775) Firefox tests are running out of time on CI. --- tests/bidi/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index 798f59fb7d..c333ff9d12 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -50,7 +50,7 @@ const config: Config Date: Mon, 23 Sep 2024 19:05:55 -0700 Subject: [PATCH 13/29] chore(bidi): bring to front, pdf (#32698) --- .../src/server/bidi/bidiPage.ts | 11 ++ .../src/server/bidi/bidiPdf.ts | 109 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 packages/playwright-core/src/server/bidi/bidiPdf.ts diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 180e8a651e..a50217975a 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -22,6 +22,7 @@ import * as dom from '../dom'; import * as dialog from '../dialog'; import type * as frames from '../frames'; import { Page } from '../page'; +import type * as channels from '@protocol/channels'; import type { InitScript, PageDelegate } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; @@ -32,6 +33,7 @@ import * as bidi from './third_party/bidiProtocol'; import { BidiExecutionContext } from './bidiExecutionContext'; import { BidiNetworkManager } from './bidiNetworkManager'; import { BrowserContext } from '../browserContext'; +import { BidiPDF } from './bidiPdf'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; const kPlaywrightBindingChannel = 'playwrightChannel'; @@ -48,6 +50,7 @@ export class BidiPage implements PageDelegate { private _sessionListeners: RegisteredListener[] = []; readonly _browserContext: BidiBrowserContext; readonly _networkManager: BidiNetworkManager; + private readonly _pdf: BidiPDF; _initializedPage: Page | null = null; private _initScriptIds: string[] = []; @@ -61,6 +64,7 @@ export class BidiPage implements PageDelegate { this._page = new Page(this, browserContext); this._browserContext = browserContext; this._networkManager = new BidiNetworkManager(this._session, this._page, this._onNavigationResponseStarted.bind(this)); + this._pdf = new BidiPDF(this._session); this._page.on(Page.Events.FrameDetached, (frame: frames.Frame) => this._removeContextsForFrame(frame, false)); this._sessionListeners = [ eventsHelper.addEventListener(bidiSession, 'script.realmCreated', this._onRealmCreated.bind(this)), @@ -279,6 +283,9 @@ export class BidiPage implements PageDelegate { } async bringToFront(): Promise { + await this._session.send('browsingContext.activate', { + context: this._session.sessionId, + }); } private async _updateViewport(): Promise { @@ -555,6 +562,10 @@ export class BidiPage implements PageDelegate { async resetForReuse(): Promise { } + async pdf(options: channels.PagePdfParams): Promise { + return this._pdf.generate(options); + } + async getFrameElement(frame: frames.Frame): Promise { const parent = frame.parentFrame(); if (!parent) diff --git a/packages/playwright-core/src/server/bidi/bidiPdf.ts b/packages/playwright-core/src/server/bidi/bidiPdf.ts new file mode 100644 index 0000000000..89fefb5260 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiPdf.ts @@ -0,0 +1,109 @@ +/** + * Copyright 2017 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 { assert } from '../../utils'; +import type * as channels from '@protocol/channels'; +import type { BidiSession } from './bidiConnection'; + +const PagePaperFormats: { [key: string]: { width: number, height: number }} = { + letter: { width: 8.5, height: 11 }, + legal: { width: 8.5, height: 14 }, + tabloid: { width: 11, height: 17 }, + ledger: { width: 17, height: 11 }, + a0: { width: 33.1, height: 46.8 }, + a1: { width: 23.4, height: 33.1 }, + a2: { width: 16.54, height: 23.4 }, + a3: { width: 11.7, height: 16.54 }, + a4: { width: 8.27, height: 11.7 }, + a5: { width: 5.83, height: 8.27 }, + a6: { width: 4.13, height: 5.83 }, +}; + +const unitToPixels: { [key: string]: number } = { + 'px': 1, + 'in': 96, + 'cm': 37.8, + 'mm': 3.78 +}; + +function convertPrintParameterToInches(text: string | undefined): number | undefined { + if (text === undefined) + return undefined; + let unit = text.substring(text.length - 2).toLowerCase(); + let valueText = ''; + if (unitToPixels.hasOwnProperty(unit)) { + valueText = text.substring(0, text.length - 2); + } else { + // In case of unknown unit try to parse the whole parameter as number of pixels. + // This is consistent with phantom's paperSize behavior. + unit = 'px'; + valueText = text; + } + const value = Number(valueText); + assert(!isNaN(value), 'Failed to parse parameter value: ' + text); + const pixels = value * unitToPixels[unit]; + return pixels / 96; +} + +export class BidiPDF { + private _session: BidiSession; + + constructor(session: BidiSession) { + this._session = session; + } + + async generate(options: channels.PagePdfParams): Promise { + const { + scale = 1, + printBackground = false, + landscape = false, + pageRanges = '', + margin = {}, + } = options; + + let paperWidth = 8.5; + let paperHeight = 11; + if (options.format) { + const format = PagePaperFormats[options.format.toLowerCase()]; + assert(format, 'Unknown paper format: ' + options.format); + paperWidth = format.width; + paperHeight = format.height; + } else { + paperWidth = convertPrintParameterToInches(options.width) || paperWidth; + paperHeight = convertPrintParameterToInches(options.height) || paperHeight; + } + + const { data } = await this._session.send('browsingContext.print', { + context: this._session.sessionId, + background: printBackground, + margin: { + bottom: convertPrintParameterToInches(margin.bottom) || 0, + left: convertPrintParameterToInches(margin.left) || 0, + right: convertPrintParameterToInches(margin.right) || 0, + top: convertPrintParameterToInches(margin.top) || 0 + }, + orientation: landscape ? 'landscape' : 'portrait', + page: { + width: paperWidth, + height: paperHeight + }, + pageRanges: pageRanges ? pageRanges.split(',').map(r => r.trim()) : undefined, + scale, + }); + return Buffer.from(data, 'base64'); + } +} From 7c3dd70bf6d79d37ad6d4b651be43131fa48c902 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:08:01 -0700 Subject: [PATCH 14/29] feat(webkit): roll to r2081 (#32738) --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 2012fc1c9e..cba2baf730 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2080", + "revision": "2081", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From 8649b13f2527e5b130478baddacf023185833ed6 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 23 Sep 2024 19:13:45 -0700 Subject: [PATCH 15/29] chore: start putting tv-recorder ui together (#32776) --- .../server/recorder/recorderInTraceViewer.ts | 4 + packages/recorder/src/recorder.tsx | 38 +- packages/trace-viewer/src/ui/actionList.tsx | 10 +- packages/trace-viewer/src/ui/consoleTab.tsx | 2 +- packages/trace-viewer/src/ui/networkTab.tsx | 4 +- packages/trace-viewer/src/ui/recorderView.tsx | 328 +++++++++++++++--- packages/trace-viewer/src/ui/sourceTab.tsx | 2 +- packages/trace-viewer/src/ui/workbench.tsx | 11 +- packages/web/src/components/sourceChooser.css | 20 ++ packages/web/src/components/sourceChooser.tsx | 58 ++++ packages/web/src/components/tabbedPane.tsx | 12 +- packages/web/src/components/toolbar.css | 4 + packages/web/src/components/toolbar.tsx | 4 +- packages/web/src/uiUtils.ts | 5 + 14 files changed, 404 insertions(+), 98 deletions(-) create mode 100644 packages/web/src/components/sourceChooser.css create mode 100644 packages/web/src/components/sourceChooser.tsx diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts index 8da0896497..4c84547ade 100644 --- a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -43,6 +43,7 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) { super(); this._transport = transport; + this._transport.eventSink.resolve(this); this._tracePage = tracePage; this._traceServer = traceServer; this.wsEndpointForTest = wsEndpointForTest; @@ -94,6 +95,7 @@ async function openApp(trace: string, options?: TraceViewerServerOptions & { hea class RecorderTransport implements Transport { private _connected = new ManualPromise(); + readonly eventSink = new ManualPromise(); constructor() { } @@ -103,6 +105,8 @@ class RecorderTransport implements Transport { } async dispatch(method: string, params: any): Promise { + const eventSink = await this.eventSink; + eventSink.emit('event', { event: method, params }); } onclose() { diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 31bad2b70b..9d5c0feeba 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -19,6 +19,7 @@ import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { SplitView } from '@web/components/splitView'; import { TabbedPane } from '@web/components/tabbedPane'; import { Toolbar } from '@web/components/toolbar'; +import { emptySource, SourceChooser } from '@web/components/sourceChooser'; import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; import * as React from 'react'; import { CallLogView } from './callLog'; @@ -54,15 +55,7 @@ export const Recorder: React.FC = ({ if (source) return source; } - const source: Source = { - id: 'default', - isRecorded: false, - text: '', - language: 'javascript', - label: '', - highlight: [] - }; - return source; + return emptySource(); }, [sources, fileId]); const [locator, setLocator] = React.useState(''); @@ -152,10 +145,10 @@ export const Recorder: React.FC = ({ }}>
Target:
- + { + setFileId(fileId); + window.dispatch({ event: 'fileChanged', params: { file: fileId } }); + }} /> { window.dispatch({ event: 'clear' }); }}> @@ -184,22 +177,3 @@ export const Recorder: React.FC = ({ />
; }; - -function renderSourceOptions(sources: Source[]): React.ReactNode { - const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1'); - const renderOption = (source: Source): React.ReactNode => ( - - ); - - const hasGroup = sources.some(s => s.group); - if (hasGroup) { - const groups = new Set(sources.map(s => s.group)); - return [...groups].filter(Boolean).map(group => ( - - {sources.filter(s => s.group === group).map(source => renderOption(source))} - - )); - } - - return sources.map(source => renderOption(source)); -} diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 3935447e7d..55f4f4028a 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -32,9 +32,9 @@ export interface ActionListProps { selectedTime: Boundaries | undefined, setSelectedTime: (time: Boundaries | undefined) => void, sdkLanguage: Language | undefined; - onSelected: (action: ActionTraceEventInContext) => void, - onHighlighted: (action: ActionTraceEventInContext | undefined) => void, - revealConsole: () => void, + onSelected?: (action: ActionTraceEventInContext) => void, + onHighlighted?: (action: ActionTraceEventInContext | undefined) => void, + revealConsole?: () => void, isLive?: boolean, } @@ -67,8 +67,8 @@ export const ActionList: React.FC = ({ treeState={treeState} setTreeState={setTreeState} selectedItem={selectedItem} - onSelected={item => onSelected(item.action!)} - onHighlighted={item => onHighlighted(item?.action)} + onSelected={item => onSelected?.(item.action!)} + onHighlighted={item => onHighlighted?.(item?.action)} onAccepted={item => setSelectedTime({ minimum: item.action!.startTime, maximum: item.action!.endTime })} isError={item => !!item.action?.error?.message} isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)} diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index b2947f5011..3ae847ef50 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -107,7 +107,7 @@ export const ConsoleTab: React.FunctionComponent<{ boundaries: Boundaries, consoleModel: ConsoleTabModel, selectedTime: Boundaries | undefined, - onEntryHovered: (entry: ConsoleEntry | undefined) => void, + onEntryHovered?: (entry: ConsoleEntry | undefined) => void, onAccepted: (entry: ConsoleEntry) => void, }> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => { if (!consoleModel.entries.length) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 207dd33547..62b139be0d 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -65,7 +65,7 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT export const NetworkTab: React.FunctionComponent<{ boundaries: Boundaries, networkModel: NetworkTabModel, - onEntryHovered: (entry: Entry | undefined) => void, + onEntryHovered?: (entry: Entry | undefined) => void, }> = ({ boundaries, networkModel, onEntryHovered }) => { const [sorting, setSorting] = React.useState(undefined); const [selectedEntry, setSelectedEntry] = React.useState(undefined); @@ -95,7 +95,7 @@ export const NetworkTab: React.FunctionComponent<{ items={renderedEntries} selectedItem={selectedEntry} onSelected={item => setSelectedEntry(item)} - onHighlighted={item => onEntryHovered(item?.resource)} + onHighlighted={item => onEntryHovered?.(item?.resource)} columns={visibleColumns(!!selectedEntry, renderedEntries)} columnTitle={columnTitle} columnWidths={columnWidths} diff --git a/packages/trace-viewer/src/ui/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx index 945ac86fc0..159536d925 100644 --- a/packages/trace-viewer/src/ui/recorderView.tsx +++ b/packages/trace-viewer/src/ui/recorderView.tsx @@ -14,52 +14,52 @@ limitations under the License. */ -import * as React from 'react'; -import './recorderView.css'; -import { MultiTraceModel } from './modelUtil'; -import type { SourceLocation } from './modelUtil'; -import { Workbench } from './workbench'; +import type { Language } from '@isomorphic/locatorGenerators'; import type { Mode, Source } from '@recorder/recorderTypes'; +import { SplitView } from '@web/components/splitView'; +import type { TabbedPaneTabModel } from '@web/components/tabbedPane'; +import { TabbedPane } from '@web/components/tabbedPane'; +import { sha1, useSetting } from '@web/uiUtils'; +import * as React from 'react'; import type { ContextEntry } from '../entries'; +import type { Boundaries } from '../geometry'; +import { ActionList } from './actionList'; +import { ConsoleTab, useConsoleTabModel } from './consoleTab'; +import { InspectorTab } from './inspectorTab'; +import type * as modelUtil from './modelUtil'; +import type { SourceLocation } from './modelUtil'; +import { MultiTraceModel } from './modelUtil'; +import { NetworkTab, useNetworkTabModel } from './networkTab'; +import './recorderView.css'; +import { collectSnapshots, extendSnapshot, SnapshotView } from './snapshotTab'; +import { SourceTab } from './sourceTab'; +import { Toolbar } from '@web/components/toolbar'; +import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; +import { toggleTheme } from '@web/theme'; +import { SourceChooser } from '@web/components/sourceChooser'; const searchParams = new URLSearchParams(window.location.search); const guid = searchParams.get('ws'); -const trace = searchParams.get('trace') + '.json'; +const traceLocation = searchParams.get('trace') + '.json'; export const RecorderView: React.FunctionComponent = () => { const [connection, setConnection] = React.useState(null); const [sources, setSources] = React.useState([]); + const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean, sha1: string } | undefined>(); + const [mode, setMode] = React.useState('none'); + const [counter, setCounter] = React.useState(0); + const pollTimer = React.useRef(null); + React.useEffect(() => { const wsURL = new URL(`../${guid}`, window.location.toString()); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); const webSocket = new WebSocket(wsURL.toString()); - setConnection(new Connection(webSocket, { setSources })); + setConnection(new Connection(webSocket, { setMode, setSources })); return () => { webSocket.close(); }; }, []); - React.useEffect(() => { - if (!connection) - return; - connection.setMode('recording'); - }, [connection]); - - return
- -
; -}; - -export const TraceView: React.FC<{ - traceLocation: string, - sources: Source[], -}> = ({ traceLocation, sources }) => { - const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); - const [counter, setCounter] = React.useState(0); - const pollTimer = React.useRef(null); - React.useEffect(() => { if (pollTimer.current) clearTimeout(pollTimer.current); @@ -67,8 +67,9 @@ export const TraceView: React.FC<{ // Start polling running test. pollTimer.current = setTimeout(async () => { try { - const model = await loadSingleTraceFile(traceLocation); - setModel({ model, isLive: true }); + const result = await loadSingleTraceFile(traceLocation); + if (result.sha1 !== model?.sha1) + setModel({ ...result, isLive: true }); } catch { setModel(undefined); } finally { @@ -79,10 +80,94 @@ export const TraceView: React.FC<{ if (pollTimer.current) clearTimeout(pollTimer.current); }; - }, [counter, traceLocation]); + }, [counter, model]); + + return
+ connection?.setMode(mode)} + model={model?.model} + sources={sources} + /> +
; +}; + +async function loadSingleTraceFile(url: string): Promise<{ model: MultiTraceModel, sha1: string }> { + const params = new URLSearchParams(); + params.set('trace', url); + const response = await fetch(`contexts?${params.toString()}`); + const contextEntries = await response.json() as ContextEntry[]; + + const tokens: string[] = []; + for (const entry of contextEntries) { + entry.actions.forEach(a => tokens.push(a.type + '@' + a.startTime + '-' + a.endTime)); + entry.events.forEach(e => tokens.push(e.type + '@' + e.time)); + } + return { model: new MultiTraceModel(contextEntries), sha1: await sha1(tokens.join('|')) }; +} + +export const Workbench: React.FunctionComponent<{ + mode: Mode, + setMode: (mode: Mode) => void, + model?: modelUtil.MultiTraceModel, + sources: Source[], +}> = ({ mode, setMode, model, sources }) => { + const [fileId, setFileId] = React.useState(); + const [selectedCallId, setSelectedCallId] = React.useState(undefined); + const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting('recorderPropertiesTab', 'source'); + const [isInspecting, setIsInspectingState] = React.useState(false); + const [highlightedLocator, setHighlightedLocator] = React.useState(''); + const [selectedTime, setSelectedTime] = React.useState(); + const sourceModel = React.useRef(new Map()); + + const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { + setSelectedCallId(action?.callId); + }, []); + + const selectedAction = React.useMemo(() => { + return model?.actions.find(a => a.callId === selectedCallId); + }, [model, selectedCallId]); + + const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => { + setSelectedAction(action); + }, [setSelectedAction]); + + const selectPropertiesTab = React.useCallback((tab: string) => { + setSelectedPropertiesTab(tab); + if (tab !== 'inspector') + setIsInspectingState(false); + }, [setSelectedPropertiesTab]); + + const setIsInspecting = React.useCallback((value: boolean) => { + if (!isInspecting && value) + selectPropertiesTab('inspector'); + setIsInspectingState(value); + }, [setIsInspectingState, selectPropertiesTab, isInspecting]); + + const locatorPicked = React.useCallback((locator: string) => { + setHighlightedLocator(locator); + selectPropertiesTab('inspector'); + }, [selectPropertiesTab]); + + const consoleModel = useConsoleTabModel(model, selectedTime); + const networkModel = useNetworkTabModel(model, selectedTime); + const sdkLanguage = model?.sdkLanguage || 'javascript'; + + const inspectorTab: TabbedPaneTabModel = { + id: 'inspector', + title: 'Locator', + render: () => , + }; + + const source = React.useMemo(() => sources.find(s => s.id === fileId) || sources[0], [sources, fileId]); const fallbackLocation = React.useMemo(() => { - if (!sources.length) + if (!source) return undefined; const fallbackLocation: SourceLocation = { file: '', @@ -90,37 +175,178 @@ export const TraceView: React.FC<{ column: 0, source: { errors: [], - content: sources[0].text + content: source.text } }; return fallbackLocation; - }, [sources]); + }, [source]); - return + }; + const consoleTab: TabbedPaneTabModel = { + id: 'console', + title: 'Console', + count: consoleModel.entries.length, + render: () => setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })} + /> + }; + const networkTab: TabbedPaneTabModel = { + id: 'network', + title: 'Network', + count: networkModel.resources.length, + render: () => + }; + + const tabs: TabbedPaneTabModel[] = [ + sourceTab, + inspectorTab, + consoleTab, + networkTab, + ]; + + const { boundaries } = React.useMemo(() => { + const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 }; + if (boundaries.minimum > boundaries.maximum) { + boundaries.minimum = 0; + boundaries.maximum = 30000; + } + // Leave some nice free space on the right hand side. + boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; + return { boundaries }; + }, [model]); + + const actionList = selectPropertiesTab('console')} isLive={true} - hideTimeline={true} />; + + const actionsTab: TabbedPaneTabModel = { + id: 'actions', + title: 'Actions', + component: actionList, + }; + + const toolbar = +
+ { + setMode(mode === 'recording' ? 'standby' : 'recording'); + }}>Record + + { + setIsInspecting(!isInspecting); + }} /> + { + }} /> + { + }} /> + { + }} /> + + { + }} /> +
+
Target:
+ { + setFileId(fileId); + }} /> + { + }}> + toggleTheme()}> +
; + + const sidebarTabbedPane = ; + + const propertiesTabbedPane = ; + + const snapshotView = ; + + return
+ + {toolbar} + {snapshotView} +
} + sidebar={propertiesTabbedPane} + />} + sidebar={sidebarTabbedPane} + /> +
; }; -async function loadSingleTraceFile(url: string): Promise { - const params = new URLSearchParams(); - params.set('trace', url); - const response = await fetch(`contexts?${params.toString()}`); - const contextEntries = await response.json() as ContextEntry[]; - return new MultiTraceModel(contextEntries); -} +const SnapshotContainer: React.FunctionComponent<{ + sdkLanguage: Language, + action: modelUtil.ActionTraceEventInContext | undefined, + testIdAttributeName?: string, + isInspecting: boolean, + highlightedLocator: string, + setIsInspecting: (value: boolean) => void, + locatorPicked: (locator: string) => void, +}> = ({ sdkLanguage, action, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, locatorPicked }) => { + const snapshot = React.useMemo(() => { + const snapshot = collectSnapshots(action); + return snapshot.action || snapshot.after || snapshot.before; + }, [action]); + const snapshotUrls = React.useMemo(() => { + return snapshot ? extendSnapshot(snapshot) : undefined; + }, [snapshot]); + return ; +}; + +type ConnectionOptions = { + setSources: (sources: Source[]) => void; + setMode: (mode: Mode) => void; +}; class Connection { private _lastId = 0; private _webSocket: WebSocket; private _callbacks = new Map void, reject: (arg: Error) => void }>(); - private _options: { setSources: (sources: Source[]) => void; }; + private _options: ConnectionOptions; - constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) { + constructor(webSocket: WebSocket, options: ConnectionOptions) { this._webSocket = webSocket; this._callbacks = new Map(); this._options = options; @@ -166,5 +392,9 @@ class Connection { this._options.setSources(sources); window.playwrightSourcesEchoForTest = sources; } + if (method === 'setMode') { + const { mode } = params as { mode: Mode }; + this._options.setMode(mode); + } } } diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index ce54b34d53..d130499207 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -28,7 +28,7 @@ import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; export const SourceTab: React.FunctionComponent<{ - stack: StackFrame[] | undefined, + stack?: StackFrame[], stackFrameLocation: 'bottom' | 'right', sources: Map, rootDir?: string, diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 13c5f5fd0e..2905bb052f 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -60,8 +60,7 @@ export const Workbench: React.FunctionComponent<{ }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { const [selectedCallId, setSelectedCallId] = React.useState(undefined); const [revealedError, setRevealedError] = React.useState(undefined); - - const [highlightedAction, setHighlightedAction] = React.useState(); + const [highlightedCallId, setHighlightedCallId] = React.useState(); const [highlightedEntry, setHighlightedEntry] = React.useState(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState(); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); @@ -77,6 +76,14 @@ export const Workbench: React.FunctionComponent<{ setRevealedError(undefined); }, []); + const highlightedAction = React.useMemo(() => { + return model?.actions.find(a => a.callId === highlightedCallId); + }, [model, highlightedCallId]); + + const setHighlightedAction = React.useCallback((highlightedAction: modelUtil.ActionTraceEventInContext | undefined) => { + setHighlightedCallId(highlightedAction?.callId); + }, []); + const sources = React.useMemo(() => model?.sources || new Map(), [model]); React.useEffect(() => { diff --git a/packages/web/src/components/sourceChooser.css b/packages/web/src/components/sourceChooser.css new file mode 100644 index 0000000000..60ac74bf85 --- /dev/null +++ b/packages/web/src/components/sourceChooser.css @@ -0,0 +1,20 @@ +/* + 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. +*/ + +.source-chooser { + border: none; + background: none; + outline: none; + color: var(--vscode-sideBarTitle-foreground); + min-width: 100px; +} diff --git a/packages/web/src/components/sourceChooser.tsx b/packages/web/src/components/sourceChooser.tsx new file mode 100644 index 0000000000..0645480a03 --- /dev/null +++ b/packages/web/src/components/sourceChooser.tsx @@ -0,0 +1,58 @@ +/** + * 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 * as React from 'react'; +import type { Source } from '@recorder/recorderTypes'; + +export const SourceChooser: React.FC<{ + sources: Source[], + fileId: string | undefined, + setFileId: (fileId: string) => void, +}> = ({ sources, fileId, setFileId }) => { + return ; +}; + +function renderSourceOptions(sources: Source[]): React.ReactNode { + const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1'); + const renderOption = (source: Source): React.ReactNode => ( + + ); + + const hasGroup = sources.some(s => s.group); + if (hasGroup) { + const groups = new Set(sources.map(s => s.group)); + return [...groups].filter(Boolean).map(group => ( + + {sources.filter(s => s.group === group).map(source => renderOption(source))} + + )); + } + + return sources.map(source => renderOption(source)); +} + +export function emptySource(): Source { + return { + id: 'default', + isRecorded: false, + text: '', + language: 'javascript', + label: '', + highlight: [] + }; +} \ No newline at end of file diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index b9872294df..5df94ec4c3 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -32,11 +32,13 @@ export const TabbedPane: React.FunctionComponent<{ tabs: TabbedPaneTabModel[], leftToolbar?: React.ReactElement[], rightToolbar?: React.ReactElement[], - selectedTab: string, - setSelectedTab: (tab: string) => void, + selectedTab?: string, + setSelectedTab?: (tab: string) => void, dataTestId?: string, mode?: 'default' | 'select', }> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => { + if (!selectedTab) + selectedTab = tabs[0].id; if (!mode) mode = 'default'; return
@@ -60,7 +62,7 @@ export const TabbedPane: React.FunctionComponent<{
} {mode === 'select' &&