From 141c6a9c8159d87fee0f01ad8300b4bc5ebe0cbb Mon Sep 17 00:00:00 2001 From: Debbie O'Brien Date: Wed, 29 May 2024 17:19:49 +0200 Subject: [PATCH 01/15] docs: add video on running tests (#31066) --- docs/src/getting-started-vscode-js.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/src/getting-started-vscode-js.md b/docs/src/getting-started-vscode-js.md index 120914ff14..cf1c8f0844 100644 --- a/docs/src/getting-started-vscode-js.md +++ b/docs/src/getting-started-vscode-js.md @@ -45,8 +45,14 @@ The testing sidebar can be opened by clicking on the testing icon in the activit You can run a single test by clicking the green triangle next to your test block to run your test. Playwright will run through each line of the test and when it finishes you will see a green tick next to your test block as well as the time it took to run the test. + + ![run a single test](https://github.com/microsoft/playwright/assets/13063165/69dbccfc-4e9f-40e7-bcdf-7d5c5a11f988) + ### Run tests and show browsers You can also run your tests and show the browsers by selecting the option **Show Browsers** in the testing sidebar. Then when you click the green triangle to run your test the browser will open and you will visually see it run through your test. Leave this selected if you want browsers open for all your tests or uncheck it if you prefer your tests to run in headless mode with no browser open. From 97a82e6b77afd1df154444677bd87e603769d20b Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Wed, 29 May 2024 08:28:26 -0700 Subject: [PATCH 02/15] feat(firefox): roll to r1452 (#31068) Fixes https://github.com/microsoft/playwright/issues/31039 Closes https://github.com/microsoft/playwright/pull/31069 --- packages/playwright-core/browsers.json | 4 ++-- tests/page/page-basic.spec.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 4f0533c3e9..26417b7054 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,13 +15,13 @@ }, { "name": "firefox", - "revision": "1451", + "revision": "1452", "installByDefault": true, "browserVersion": "126.0" }, { "name": "firefox-beta", - "revision": "1451", + "revision": "1452", "installByDefault": false, "browserVersion": "127.0b3" }, diff --git a/tests/page/page-basic.spec.ts b/tests/page/page-basic.spec.ts index 38cb5340c0..0b99d0c8c5 100644 --- a/tests/page/page-basic.spec.ts +++ b/tests/page/page-basic.spec.ts @@ -255,8 +255,7 @@ it('frame.press should work', async ({ page, server }) => { expect(await frame.evaluate(() => document.querySelector('textarea').value)).toBe('a'); }); -it('has navigator.webdriver set to true', async ({ page, browserName }) => { - it.skip(browserName === 'firefox'); +it('has navigator.webdriver set to true', async ({ page }) => { expect(await page.evaluate(() => navigator.webdriver)).toBe(true); }); From 6e9c31f93bdedf5e81935d9e603d75af3609679b Mon Sep 17 00:00:00 2001 From: Mathias Leppich Date: Wed, 29 May 2024 18:07:40 +0200 Subject: [PATCH 03/15] fix(runner): don't write last run info when listing tests (#31062) --- packages/playwright/src/runner/runner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index 467911dd41..03e8d861e4 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -94,7 +94,8 @@ export class Runner { if (modifiedResult && modifiedResult.status) status = modifiedResult.status; - await writeLastRunInfo(testRun, status); + if (!listOnly) + await writeLastRunInfo(testRun, status); await reporter.onExit(); From f93da4092521892307b150aaeeffa465a67a6bda Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 29 May 2024 17:20:38 -0700 Subject: [PATCH 04/15] feat(webkit): roll to r2014 (#31074) Closes https://github.com/microsoft/playwright/pull/31059 Closes https://github.com/microsoft/playwright/pull/31012 Reference https://github.com/microsoft/playwright-browsers/issues/795 --- packages/playwright-core/browsers.json | 2 +- packages/playwright-core/src/server/webkit/wkBrowser.ts | 4 +++- tests/config/browserTest.ts | 6 ++++-- tests/library/browsercontext-add-cookies.spec.ts | 6 +++--- tests/library/browsercontext-cookies.spec.ts | 7 +++++-- tests/library/browsercontext-fetch.spec.ts | 4 +++- tests/library/browsercontext-route.spec.ts | 4 ++-- 7 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 26417b7054..25384e9345 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2013", + "revision": "2014", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts index 4b10dfd3fe..b65ca55c21 100644 --- a/packages/playwright-core/src/server/webkit/wkBrowser.ts +++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts @@ -267,7 +267,9 @@ export class WKBrowserContext extends BrowserContext { const cc = network.rewriteCookies(cookies).map(c => ({ ...c, session: c.expires === -1 || c.expires === undefined, - expires: c.expires && c.expires !== -1 ? c.expires * 1000 : c.expires + expires: c.expires && c.expires !== -1 ? c.expires * 1000 : c.expires, + // TODO: make WebKit on linux work without eplicit sameSite. + sameSite: c.sameSite ?? (process.platform === 'linux' ? 'Lax' : undefined) })) as Protocol.Playwright.SetCookieParam[]; await this._browser._browserSession.send('Playwright.setCookies', { cookies: cc, browserContextId: this._browserContextId }); } diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 02555d42eb..8b4fdc27b9 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -67,10 +67,12 @@ const test = baseTest.extend await run(false); }, { scope: 'worker' }], - defaultSameSiteCookieValue: [async ({ browserName, browserMajorVersion, channel }, run) => { + defaultSameSiteCookieValue: [async ({ browserName, browserMajorVersion, channel, isLinux }, run) => { if (browserName === 'chromium') await run('Lax'); - else if (browserName === 'webkit') + else if (browserName === 'webkit' && isLinux) + await run('Lax'); + else if (browserName === 'webkit' && !isLinux) await run('None'); else if (browserName === 'firefox' && channel === 'firefox-beta') await run(browserMajorVersion >= 103 && browserMajorVersion < 110 ? 'Lax' : 'None'); diff --git a/tests/library/browsercontext-add-cookies.spec.ts b/tests/library/browsercontext-add-cookies.spec.ts index bdfe14ef0a..f35ea8b327 100644 --- a/tests/library/browsercontext-add-cookies.spec.ts +++ b/tests/library/browsercontext-add-cookies.spec.ts @@ -24,7 +24,7 @@ it('should work @smoke', async ({ context, page, server }) => { await context.addCookies([{ url: server.EMPTY_PAGE, name: 'password', - value: '123456' + value: '123456', }]); expect(await page.evaluate(() => document.cookie)).toEqual('password=123456'); }); @@ -224,7 +224,7 @@ it('should have |expires| set to |-1| for session cookies', async ({ context, se expect(cookies[0].expires).toBe(-1); }); -it('should set cookie with reasonable defaults', async ({ context, server, browserName }) => { +it('should set cookie with reasonable defaults', async ({ context, server, defaultSameSiteCookieValue }) => { await context.addCookies([{ url: server.EMPTY_PAGE, name: 'defaults', @@ -239,7 +239,7 @@ it('should set cookie with reasonable defaults', async ({ context, server, brows expires: -1, httpOnly: false, secure: false, - sameSite: browserName === 'chromium' ? 'Lax' : 'None', + sameSite: defaultSameSiteCookieValue, }]); }); diff --git a/tests/library/browsercontext-cookies.spec.ts b/tests/library/browsercontext-cookies.spec.ts index a53f989886..df923ef3ff 100644 --- a/tests/library/browsercontext-cookies.spec.ts +++ b/tests/library/browsercontext-cookies.spec.ts @@ -384,7 +384,7 @@ it('should support requestStorageAccess', async ({ page, server, channel, browse server.waitForRequest('/title.html'), frame.evaluate(() => fetch('/title.html')) ]); - if (!isMac && browserName === 'webkit') + if (isWindows && browserName === 'webkit') expect(serverRequest.headers.cookie).toBe('name=value'); else expect(serverRequest.headers.cookie).toBeFalsy(); @@ -396,7 +396,10 @@ it('should support requestStorageAccess', async ({ page, server, channel, browse server.waitForRequest('/title.html'), frame.evaluate(() => fetch('/title.html')) ]); - expect(serverRequest.headers.cookie).toBe('name=value'); + if (isLinux && browserName === 'webkit') + expect(serverRequest.headers.cookie).toBe(undefined); + else + expect(serverRequest.headers.cookie).toBe('name=value'); } } }); diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index f4088359cf..99fd0265a3 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -1202,7 +1202,7 @@ it('fetch should not throw on long set-cookie value', async ({ context, server } expect(cookies.map(c => c.name)).toContain('bar'); }); -it('should support set-cookie with SameSite and without Secure attribute over HTTP', async ({ page, server, browserName, isWindows }) => { +it('should support set-cookie with SameSite and without Secure attribute over HTTP', async ({ page, server, browserName, isWindows, isLinux }) => { for (const value of ['None', 'Lax', 'Strict']) { await it.step(`SameSite=${value}`, async () => { server.setRoute('/empty.html', (req, res) => { @@ -1213,6 +1213,8 @@ it('should support set-cookie with SameSite and without Secure attribute over HT const [cookie] = await page.context().cookies(); if (browserName === 'chromium' && value === 'None') expect(cookie).toBeFalsy(); + else if (browserName === 'webkit' && isLinux && value === 'None') + expect(cookie).toBeFalsy(); else if (browserName === 'webkit' && isWindows) expect(cookie.sameSite).toBe('None'); else diff --git a/tests/library/browsercontext-route.spec.ts b/tests/library/browsercontext-route.spec.ts index a597cf1157..25d965c1b6 100644 --- a/tests/library/browsercontext-route.spec.ts +++ b/tests/library/browsercontext-route.spec.ts @@ -109,7 +109,7 @@ it('should fall back to context.route', async ({ browser, server }) => { await context.close(); }); -it('should support Set-Cookie header', async ({ contextFactory, server, browserName, defaultSameSiteCookieValue }) => { +it('should support Set-Cookie header', async ({ contextFactory, defaultSameSiteCookieValue }) => { const context = await contextFactory(); const page = await context.newPage(); await page.route('https://example.com/', (route, request) => { @@ -152,7 +152,7 @@ it('should ignore secure Set-Cookie header for insecure requests', async ({ cont expect(await context.cookies()).toEqual([]); }); -it('should use Set-Cookie header in future requests', async ({ contextFactory, server, browserName, defaultSameSiteCookieValue }) => { +it('should use Set-Cookie header in future requests', async ({ contextFactory, server, defaultSameSiteCookieValue }) => { const context = await contextFactory(); const page = await context.newPage(); From ba5b46044469b783b64c94b9986460c38e020c77 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 29 May 2024 18:05:17 -0700 Subject: [PATCH 05/15] chore: move artifacts recording to TestLifecycleInstrumentation (#30935) The spirit of this change is reverting #23153. Since that time, we have moved tracing and `artifactsDir` lifetime into the test runner, so the reason for revert is mitigated. Fixes #30287, fixes #30718, fixes #30959. --- .../playwright-core/src/client/tracing.ts | 13 +- packages/playwright/src/common/globals.ts | 16 ++ packages/playwright/src/index.ts | 169 +++++++++++------- packages/playwright/src/worker/testInfo.ts | 1 - packages/playwright/src/worker/workerMain.ts | 14 +- tests/config/testModeFixtures.ts | 3 +- .../playwright.artifacts.spec.ts | 3 - .../playwright-test/playwright.trace.spec.ts | 127 ++++++++++++- 8 files changed, 266 insertions(+), 80 deletions(-) diff --git a/packages/playwright-core/src/client/tracing.ts b/packages/playwright-core/src/client/tracing.ts index 7330cd9f26..187e1980b3 100644 --- a/packages/playwright-core/src/client/tracing.ts +++ b/packages/playwright-core/src/client/tracing.ts @@ -34,8 +34,8 @@ export class Tracing extends ChannelOwner implements ap } async start(options: { name?: string, title?: string, snapshots?: boolean, screenshots?: boolean, sources?: boolean, _live?: boolean } = {}) { - this._includeSources = !!options.sources; - const traceName = await this._wrapApiCall(async () => { + await this._wrapApiCall(async () => { + this._includeSources = !!options.sources; await this._channel.tracingStart({ name: options.name, snapshots: options.snapshots, @@ -43,14 +43,15 @@ export class Tracing extends ChannelOwner implements ap live: options._live, }); const response = await this._channel.tracingStartChunk({ name: options.name, title: options.title }); - return response.traceName; + await this._startCollectingStacks(response.traceName); }, true); - await this._startCollectingStacks(traceName); } async startChunk(options: { name?: string, title?: string } = {}) { - const { traceName } = await this._channel.tracingStartChunk(options); - await this._startCollectingStacks(traceName); + await this._wrapApiCall(async () => { + const { traceName } = await this._channel.tracingStartChunk(options); + await this._startCollectingStacks(traceName); + }, true); } private async _startCollectingStacks(traceName: string) { diff --git a/packages/playwright/src/common/globals.ts b/packages/playwright/src/common/globals.ts index 746e1ec847..5b7abef1a9 100644 --- a/packages/playwright/src/common/globals.ts +++ b/packages/playwright/src/common/globals.ts @@ -42,3 +42,19 @@ export function setIsWorkerProcess() { export function isWorkerProcess() { return _isWorkerProcess; } + +export interface TestLifecycleInstrumentation { + onTestBegin?(): Promise; + onTestFunctionEnd?(): Promise; + onTestEnd?(): Promise; +} + +let _testLifecycleInstrumentation: TestLifecycleInstrumentation | undefined; + +export function setTestLifecycleInstrumentation(instrumentation: TestLifecycleInstrumentation | undefined) { + _testLifecycleInstrumentation = instrumentation; +} + +export function testLifecycleInstrumentation() { + return _testLifecycleInstrumentation; +} diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index c196dc2d2f..3fbaf33585 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -16,15 +16,14 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core'; -import * as playwrightLibrary from 'playwright-core'; +import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video, PageScreenshotOptions } from 'playwright-core'; import { createGuid, debugMode, addInternalStackPrefix, isString, asLocator, jsonStringifyForceASCII } from 'playwright-core/lib/utils'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test'; import type { TestInfoImpl } from './worker/testInfo'; import { rootTestType } from './common/testType'; import type { ContextReuseMode } from './common/config'; import type { ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation'; -import { currentTestInfo } from './common/globals'; +import { currentTestInfo, setTestLifecycleInstrumentation, type TestLifecycleInstrumentation } from './common/globals'; export { expect } from './matchers/expect'; export const _baseTest: TestType<{}, {}> = rootTestType.test; @@ -45,11 +44,12 @@ if ((process as any)['__pw_initiator__']) { type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & { _combinedContextOptions: BrowserContextOptions, _setupContextOptions: void; - _setupArtifacts: void; _contextFactory: (options?: BrowserContextOptions) => Promise; }; type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { + // Same as "playwright", but exposed so that our internal tests can override it. + _playwrightImpl: PlaywrightWorkerArgs['playwright']; _browserOptions: LaunchOptions; _optionContextReuseMode: ContextReuseMode, _optionConnectOptions: PlaywrightWorkerOptions['connectOptions'], @@ -59,9 +59,14 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { const playwrightFixtures: Fixtures = ({ defaultBrowserType: ['chromium', { scope: 'worker', option: true }], browserName: [({ defaultBrowserType }, use) => use(defaultBrowserType), { scope: 'worker', option: true }], - playwright: [async ({}, use) => { - await use(require('playwright-core')); + _playwrightImpl: [({}, use) => use(require('playwright-core')), { scope: 'worker' }], + + playwright: [async ({ _playwrightImpl, screenshot }, use) => { + await connector.setPlaywright(_playwrightImpl, screenshot); + await use(_playwrightImpl); + await connector.setPlaywright(undefined, screenshot); }, { scope: 'worker', _hideStep: true } as any], + headless: [({ launchOptions }, use) => use(launchOptions.headless ?? true), { scope: 'worker', option: true }], channel: [({ launchOptions }, use) => use(launchOptions.channel), { scope: 'worker', option: true }], launchOptions: [{}, { scope: 'worker', option: true }], @@ -222,7 +227,7 @@ const playwrightFixtures: Fixtures = ({ _setupContextOptions: [async ({ playwright, _combinedContextOptions, actionTimeout, navigationTimeout, testIdAttribute }, use, testInfo) => { if (testIdAttribute) - playwrightLibrary.selectors.setTestIdAttribute(testIdAttribute); + playwright.selectors.setTestIdAttribute(testIdAttribute); testInfo.snapshotSuffix = process.platform; if (debugMode()) testInfo.setTimeout(0); @@ -243,58 +248,6 @@ const playwrightFixtures: Fixtures = ({ } }, { auto: 'all-hooks-included', _title: 'context configuration' } as any], - _setupArtifacts: [async ({ playwright, screenshot }, use, testInfo) => { - const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot); - await artifactsRecorder.willStartTest(testInfo as TestInfoImpl); - const csiListener: ClientInstrumentationListener = { - onApiCallBegin: (apiName: string, params: Record, frames: StackFrame[], userData: any, out: { stepId?: string }) => { - const testInfo = currentTestInfo(); - if (!testInfo || apiName.includes('setTestIdAttribute')) - return { userObject: null }; - const step = testInfo._addStep({ - location: frames[0] as any, - category: 'pw:api', - title: renderApiCall(apiName, params), - apiName, - params, - }); - userData.userObject = step; - out.stepId = step.stepId; - }, - onApiCallEnd: (userData: any, error?: Error) => { - const step = userData.userObject; - step?.complete({ error }); - }, - onWillPause: () => { - currentTestInfo()?.setTimeout(0); - }, - runAfterCreateBrowserContext: async (context: BrowserContext) => { - await artifactsRecorder?.didCreateBrowserContext(context); - const testInfo = currentTestInfo(); - if (testInfo) - attachConnectedHeaderIfNeeded(testInfo, context.browser()); - }, - runAfterCreateRequestContext: async (context: APIRequestContext) => { - await artifactsRecorder?.didCreateRequestContext(context); - }, - runBeforeCloseBrowserContext: async (context: BrowserContext) => { - await artifactsRecorder?.willCloseBrowserContext(context); - }, - runBeforeCloseRequestContext: async (context: APIRequestContext) => { - await artifactsRecorder?.willCloseRequestContext(context); - }, - }; - - const clientInstrumentation = (playwright as any)._instrumentation as ClientInstrumentation; - clientInstrumentation.addListener(csiListener); - - await use(); - - clientInstrumentation.removeListener(csiListener); - await artifactsRecorder.didFinishTest(); - - }, { auto: 'all-hooks-included', _title: 'trace recording' } as any], - _contextFactory: [async ({ browser, video, _reuseContext }, use, testInfo) => { const testInfoImpl = testInfo as TestInfoImpl; const videoMode = normalizeVideoMode(video); @@ -471,7 +424,7 @@ class ArtifactsRecorder { private _playwright: Playwright; private _artifactsDir: string; private _screenshotMode: ScreenshotMode; - private _screenshotOptions: { mode: ScreenshotMode } & Pick | undefined; + private _screenshotOptions: { mode: ScreenshotMode } & Pick | undefined; private _temporaryScreenshots: string[] = []; private _temporaryArtifacts: string[] = []; private _reusedContexts = new Set(); @@ -496,7 +449,6 @@ class ArtifactsRecorder { async willStartTest(testInfo: TestInfoImpl) { this._testInfo = testInfo; - testInfo._onDidFinishTestFunction = () => this.didFinishTestFunction(); // Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not // overwrite previous screenshots. @@ -678,6 +630,101 @@ function tracing() { return (test.info() as TestInfoImpl)._tracing; } +class InstrumentationConnector implements TestLifecycleInstrumentation, ClientInstrumentationListener { + private _playwright: PlaywrightWorkerArgs['playwright'] | undefined; + private _screenshot: ScreenshotOption = 'off'; + private _artifactsRecorder: ArtifactsRecorder | undefined; + private _testIsRunning = false; + + constructor() { + setTestLifecycleInstrumentation(this); + } + + async setPlaywright(playwright: PlaywrightWorkerArgs['playwright'] | undefined, screenshot: ScreenshotOption) { + if (this._playwright) { + if (this._testIsRunning) { + // When "playwright" is destroyed during a test, collect artifacts immediately. + await this.onTestEnd(); + } + const clientInstrumentation = (this._playwright as any)._instrumentation as ClientInstrumentation; + clientInstrumentation.removeListener(this); + } + this._playwright = playwright; + this._screenshot = screenshot; + if (this._playwright) { + const clientInstrumentation = (this._playwright as any)._instrumentation as ClientInstrumentation; + clientInstrumentation.addListener(this); + if (this._testIsRunning) { + // When "playwright" is created during a test, wire it up immediately. + await this.onTestBegin(); + } + } + } + + async onTestBegin() { + this._testIsRunning = true; + if (this._playwright) { + this._artifactsRecorder = new ArtifactsRecorder(this._playwright, tracing().artifactsDir(), this._screenshot); + await this._artifactsRecorder.willStartTest(currentTestInfo() as TestInfoImpl); + } + } + + async onTestFunctionEnd() { + await this._artifactsRecorder?.didFinishTestFunction(); + } + + async onTestEnd() { + await this._artifactsRecorder?.didFinishTest(); + this._artifactsRecorder = undefined; + this._testIsRunning = false; + } + + onApiCallBegin(apiName: string, params: Record, frames: StackFrame[], userData: any, out: { stepId?: string }) { + const testInfo = currentTestInfo(); + if (!testInfo || apiName.includes('setTestIdAttribute')) + return { userObject: null }; + const step = testInfo._addStep({ + location: frames[0] as any, + category: 'pw:api', + title: renderApiCall(apiName, params), + apiName, + params, + }); + userData.userObject = step; + out.stepId = step.stepId; + } + + onApiCallEnd(userData: any, error?: Error) { + const step = userData.userObject; + step?.complete({ error }); + } + + onWillPause() { + currentTestInfo()?.setTimeout(0); + } + + async runAfterCreateBrowserContext(context: BrowserContext) { + await this._artifactsRecorder?.didCreateBrowserContext(context); + const testInfo = currentTestInfo(); + if (testInfo) + attachConnectedHeaderIfNeeded(testInfo, context.browser()); + } + + async runAfterCreateRequestContext(context: APIRequestContext) { + await this._artifactsRecorder?.didCreateRequestContext(context); + } + + async runBeforeCloseBrowserContext(context: BrowserContext) { + await this._artifactsRecorder?.willCloseBrowserContext(context); + } + + async runBeforeCloseRequestContext(context: APIRequestContext) { + await this._artifactsRecorder?.willCloseRequestContext(context); + } +} + +const connector = new InstrumentationConnector(); + export const test = _baseTest.extend(playwrightFixtures); export { defineConfig } from './common/configLoader'; diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 1e9e1f9c46..b6e57ee36e 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -68,7 +68,6 @@ export class TestInfoImpl implements TestInfo { readonly _projectInternal: FullProjectInternal; readonly _configInternal: FullConfigInternal; private readonly _steps: TestStepInternal[] = []; - _onDidFinishTestFunction: (() => Promise) | undefined; private readonly _stages: TestStage[] = []; _hasNonRetriableError = false; _hasUnhandledError = false; diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index ea2cdbaebc..3237104520 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -17,7 +17,7 @@ import { colors } from 'playwright-core/lib/utilsBundle'; import { debugTest, relativeFilePath, serializeError } from '../util'; import { type TestBeginPayload, type TestEndPayload, type RunPayload, type DonePayload, type WorkerInitParams, type TeardownErrorsPayload, stdioChunkToParams } from '../common/ipc'; -import { setCurrentTestInfo, setIsWorkerProcess } from '../common/globals'; +import { setCurrentTestInfo, setIsWorkerProcess, testLifecycleInstrumentation } from '../common/globals'; import { deserializeConfig } from '../common/configLoader'; import type { Suite, TestCase } from '../common/test'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; @@ -304,10 +304,11 @@ export class WorkerMain extends ProcessRunner { if (this._lastRunningTests.length > 10) this._lastRunningTests.shift(); let shouldRunAfterEachHooks = false; + const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 }; testInfo._allowSkips = true; await testInfo._runAsStage({ title: 'setup and test' }, async () => { - await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test' } }, async () => { + await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => { // Ideally, "trace" would be an config-level option belonging to the // test runner instead of a fixture belonging to Playwright. // However, for backwards compatibility, we have to read it from a fixture today. @@ -318,6 +319,7 @@ export class WorkerMain extends ProcessRunner { if (typeof traceFixtureRegistration.fn === 'function') throw new Error(`"trace" option cannot be a function`); await testInfo._tracing.startIfNeeded(traceFixtureRegistration.fn); + await testLifecycleInstrumentation()?.onTestBegin?.(); }); if (this._isStopped || isSkipped) { @@ -372,10 +374,10 @@ export class WorkerMain extends ProcessRunner { try { // Run "immediately upon test function finish" callback. - await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: afterHooksSlot } }, async () => testInfo._onDidFinishTestFunction?.()); + await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: tracingSlot } }, async () => { + await testLifecycleInstrumentation()?.onTestFunctionEnd?.(); + }); } catch (error) { - if (error instanceof TimeoutManagerError) - didTimeoutInAfterHooks = true; firstAfterHooksError = firstAfterHooksError ?? error; } @@ -458,8 +460,8 @@ export class WorkerMain extends ProcessRunner { }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. } - const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 }; await testInfo._runAsStage({ title: 'stop tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => { + await testLifecycleInstrumentation()?.onTestEnd?.(); await testInfo._tracing.stopIfNeeded(); }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. diff --git a/tests/config/testModeFixtures.ts b/tests/config/testModeFixtures.ts index 6b6feff7c2..40b1996719 100644 --- a/tests/config/testModeFixtures.ts +++ b/tests/config/testModeFixtures.ts @@ -30,11 +30,12 @@ export type TestModeTestFixtures = { export type TestModeWorkerFixtures = { toImplInWorkerScope: (rpcObject?: any) => any; playwright: typeof import('@playwright/test'); + _playwrightImpl: typeof import('@playwright/test'); }; export const testModeTest = test.extend({ mode: ['default', { scope: 'worker', option: true }], - playwright: [async ({ mode }, run) => { + _playwrightImpl: [async ({ mode }, run) => { const testMode = { 'default': new DefaultTestMode(), 'service': new DefaultTestMode(), diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index b666aa4b70..a7c0699c9d 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -151,10 +151,8 @@ test('should work with screenshot: on', async ({ runInlineTest }, testInfo) => { ' test-finished-1.png', 'artifacts-shared-shared-failing', ' test-failed-1.png', - ' test-failed-2.png', 'artifacts-shared-shared-passing', ' test-finished-1.png', - ' test-finished-2.png', 'artifacts-two-contexts', ' test-finished-1.png', ' test-finished-2.png', @@ -185,7 +183,6 @@ test('should work with screenshot: only-on-failure', async ({ runInlineTest }, t ' test-failed-1.png', 'artifacts-shared-shared-failing', ' test-failed-1.png', - ' test-failed-2.png', 'artifacts-two-contexts-failing', ' test-failed-1.png', ' test-failed-2.png', diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index dee65c9a52..13af444edf 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -569,14 +569,19 @@ test('should opt out of attachments', async ({ runInlineTest, server }, testInfo expect([...trace.resources.keys()].filter(f => f.startsWith('resources/'))).toHaveLength(0); }); -test('should record with custom page fixture', async ({ runInlineTest }, testInfo) => { +test('should record with custom page fixture that closes the context', async ({ runInlineTest }, testInfo) => { + // Note that original issue did not close the context, but we do not support such usecase. + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/23220' }); + const result = await runInlineTest({ 'a.spec.ts': ` import { test as base, expect } from '@playwright/test'; const test = base.extend({ myPage: async ({ browser }, use) => { - await use(await browser.newPage()); + const page = await browser.newPage(); + await use(page); + await page.close(); }, }); @@ -1112,3 +1117,121 @@ test('trace:retain-on-first-failure should create trace if request context is di expect(trace.apiNames).toContain('apiRequestContext.get'); expect(result.failed).toBe(1); }); + +test('should record trace in workerStorageState', async ({ runInlineTest }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30287' }); + + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend({ + storageState: ({ workerStorageState }, use) => use(workerStorageState), + workerStorageState: [async ({ browser }, use) => { + const page = await browser.newPage({ storageState: undefined }); + await page.setContent('
hello
'); + await page.close(); + await use(undefined); + }, { scope: 'worker' }], + }) + test('pass', async ({ page }) => { + await page.goto('data:text/html,
hi
'); + }); + `, + }, { trace: 'on' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.actionTree).toEqual([ + 'Before Hooks', + ' fixture: browser', + ' browserType.launch', + ' fixture: workerStorageState', + ' browser.newPage', + ' page.setContent', + ' page.close', + ' fixture: context', + ' browser.newContext', + ' fixture: page', + ' browserContext.newPage', + 'page.goto', + 'After Hooks', + ' fixture: page', + ' fixture: context', + ]); +}); + +test('should record trace after fixture teardown timeout', async ({ runInlineTest }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30718' }); + + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend({ + fixture: async ({}, use) => { + await use('foo'); + await new Promise(() => {}); + }, + }) + test('fails', async ({ fixture, page }) => { + await page.evaluate(() => console.log('from the page')); + }); + `, + }, { trace: 'on', timeout: '4000' }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + + const tracePath = test.info().outputPath('test-results', 'a-fails', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.actionTree).toEqual([ + 'Before Hooks', + ' fixture: fixture', + ' fixture: browser', + ' browserType.launch', + ' fixture: context', + ' browser.newContext', + ' fixture: page', + ' browserContext.newPage', + 'page.evaluate', + 'After Hooks', + ' fixture: page', + ' fixture: context', + ' fixture: fixture', + 'Worker Cleanup', + ' fixture: browser', + ]); + // Check console events to make sure that library trace is recorded. + expect(trace.events).toContainEqual(expect.objectContaining({ type: 'console', text: 'from the page' })); +}); + +test('should take a screenshot-on-failure in workerStorageState', async ({ runInlineTest }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30959' }); + + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + use: { + screenshot: 'only-on-failure', + }, + }; + `, + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend({ + storageState: ({ workerStorageState }, use) => use(workerStorageState), + workerStorageState: [async ({ browser }, use) => { + const page = await browser.newPage({ storageState: undefined }); + await page.setContent('hello world!'); + throw new Error('Failed!'); + await use(undefined); + }, { scope: 'worker' }], + }) + test('fail', async ({ page }) => { + }); + `, + }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(fs.existsSync(test.info().outputPath('test-results', 'a-fail', 'test-failed-1.png'))).toBeTruthy(); +}); From b3ee52659d520e2ae1bda068cb46eed1246f86cc Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 30 May 2024 03:15:49 -0700 Subject: [PATCH 06/15] chore(driver): roll driver to recent Node.js LTS version (#31087) --- utils/build/build-playwright-driver.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/build/build-playwright-driver.sh b/utils/build/build-playwright-driver.sh index 1454e2d9ad..975c66446b 100755 --- a/utils/build/build-playwright-driver.sh +++ b/utils/build/build-playwright-driver.sh @@ -4,7 +4,7 @@ set -x trap "cd $(pwd -P)" EXIT SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)" -NODE_VERSION="20.13.1" # autogenerated via ./update-playwright-driver-version.mjs +NODE_VERSION="20.14.0" # autogenerated via ./update-playwright-driver-version.mjs cd "$(dirname "$0")" PACKAGE_VERSION=$(node -p "require('../../package.json').version") From cb589d7fa5593a9b73964bdaba70ca07e53dfbac Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 30 May 2024 08:49:59 -0700 Subject: [PATCH 07/15] feat(chromium-tip-of-tree): roll to r1227 (#31091) 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 25384e9345..1a7f636d62 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1226", + "revision": "1227", "installByDefault": false, - "browserVersion": "127.0.6505.0" + "browserVersion": "127.0.6510.0" }, { "name": "firefox", From a1db91040ebd1753e5f1fa89e2cb7ca63f74d4c0 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 30 May 2024 08:50:22 -0700 Subject: [PATCH 08/15] feat(chromium): roll to r1121 (#31090) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 4 +- .../src/server/chromium/protocol.d.ts | 3 + .../src/server/deviceDescriptorsSource.json | 96 +++++++++---------- packages/playwright-core/types/protocol.d.ts | 3 + 5 files changed, 58 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 22e5e70742..740f3a7055 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-126.0.6478.17-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-126.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-126.0.6478.26-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-126.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 126.0.6478.17 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 126.0.6478.26 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 17.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 126.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 1a7f636d62..5215861436 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,9 +3,9 @@ "browsers": [ { "name": "chromium", - "revision": "1120", + "revision": "1121", "installByDefault": true, - "browserVersion": "126.0.6478.17" + "browserVersion": "126.0.6478.26" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/chromium/protocol.d.ts b/packages/playwright-core/src/server/chromium/protocol.d.ts index ad0ed7fc4c..efa5c36de0 100644 --- a/packages/playwright-core/src/server/chromium/protocol.d.ts +++ b/packages/playwright-core/src/server/chromium/protocol.d.ts @@ -920,6 +920,9 @@ would be `example.test`. */ export interface CookieDeprecationMetadataIssueDetails { allowedSites: string[]; + optOutPercentage: number; + isOptOutTopLevel: boolean; + operation: CookieOperation; } export type ClientHintIssueReason = "MetaTagAllowListInvalidOrigin"|"MetaTagModifiedHTML"; export interface FederatedAuthRequestIssueDetails { diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index f38e6fd777..dc6fa7c17e 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -121,7 +121,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -132,7 +132,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 360, "height": 740 @@ -143,7 +143,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 740, "height": 360 @@ -154,7 +154,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 320, "height": 658 @@ -165,7 +165,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+ landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -978,7 +978,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -989,7 +989,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1000,7 +1000,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1011,7 +1011,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1022,7 +1022,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1033,7 +1033,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1044,7 +1044,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1055,7 +1055,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1066,7 +1066,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1077,7 +1077,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1088,7 +1088,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1099,7 +1099,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1110,7 +1110,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1121,7 +1121,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1132,7 +1132,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1143,7 +1143,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1154,7 +1154,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1165,7 +1165,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1176,7 +1176,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1187,7 +1187,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1242,7 +1242,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1253,7 +1253,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1264,7 +1264,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1275,7 +1275,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1286,7 +1286,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1297,7 +1297,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1308,7 +1308,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1319,7 +1319,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1330,7 +1330,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1345,7 +1345,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1360,7 +1360,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1375,7 +1375,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1390,7 +1390,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1405,7 +1405,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1420,7 +1420,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1431,7 +1431,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1442,7 +1442,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1457,7 +1457,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36 Edg/126.0.6478.17", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36 Edg/126.0.6478.26", "screen": { "width": 1792, "height": 1120 @@ -1502,7 +1502,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1517,7 +1517,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.17 Safari/537.36 Edg/126.0.6478.17", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.26 Safari/537.36 Edg/126.0.6478.26", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/types/protocol.d.ts b/packages/playwright-core/types/protocol.d.ts index ad0ed7fc4c..efa5c36de0 100644 --- a/packages/playwright-core/types/protocol.d.ts +++ b/packages/playwright-core/types/protocol.d.ts @@ -920,6 +920,9 @@ would be `example.test`. */ export interface CookieDeprecationMetadataIssueDetails { allowedSites: string[]; + optOutPercentage: number; + isOptOutTopLevel: boolean; + operation: CookieOperation; } export type ClientHintIssueReason = "MetaTagAllowListInvalidOrigin"|"MetaTagModifiedHTML"; export interface FederatedAuthRequestIssueDetails { From 170c457a61d15d2d42cdd2860e6dd76ecedea4ce Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 30 May 2024 09:38:27 -0700 Subject: [PATCH 09/15] feat(timers): a stab at fake timers (#31075) --- docs/src/api/class-browsercontext.md | 6 + docs/src/api/class-clock.md | 86 + docs/src/api/class-page.md | 6 + packages/playwright-core/src/client/api.ts | 1 + .../src/client/browserContext.ts | 4 + packages/playwright-core/src/client/clock.ts | 57 + packages/playwright-core/src/client/page.ts | 4 + .../playwright-core/src/protocol/validator.ts | 28 + .../src/server/browserContext.ts | 3 + packages/playwright-core/src/server/clock.ts | 79 + .../dispatchers/browserContextDispatcher.ts | 20 + .../src/server/injected/DEPS.list | 5 +- .../src/server/injected/fakeTimers.ts | 23 + .../src/third_party/fake-timers-src.js | 1776 +++++++++++++++++ packages/playwright-core/types/types.d.ts | 85 + packages/protocol/src/channels.ts | 50 + packages/protocol/src/protocol.yml | 30 + tests/page/page-clock.spec.ts | 614 ++++++ utils/doclint/documentation.js | 2 + utils/generate_injected.js | 6 + 20 files changed, 2884 insertions(+), 1 deletion(-) create mode 100644 docs/src/api/class-clock.md create mode 100644 packages/playwright-core/src/client/clock.ts create mode 100644 packages/playwright-core/src/server/clock.ts create mode 100644 packages/playwright-core/src/server/injected/fakeTimers.ts create mode 100644 packages/playwright-core/src/third_party/fake-timers-src.js create mode 100644 tests/page/page-clock.spec.ts diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 511f3db10c..8cb11dbc0c 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -98,6 +98,12 @@ context.BackgroundPage += (_, backgroundPage) => ``` +## property: BrowserContext.clock +* since: v1.45 +- type: <[Clock]> + +Playwright is using [@sinonjs/fake-timers](https://github.com/sinonjs/fake-timers) to fake timers and clock. + ## event: BrowserContext.close * since: v1.8 - argument: <[BrowserContext]> diff --git a/docs/src/api/class-clock.md b/docs/src/api/class-clock.md new file mode 100644 index 0000000000..f119073e07 --- /dev/null +++ b/docs/src/api/class-clock.md @@ -0,0 +1,86 @@ +# class: Clock +* since: v1.45 + +Playwright uses [@sinonjs/fake-timers](https://github.com/sinonjs/fake-timers) for clock emulation. Clock is installed for the entire [BrowserContext], so the time +in all the pages and iframes is controlled by the same clock. + +## async method: Clock.install +* since: v1.45 + +Creates a clock and installs it globally. + +### option: Clock.install.now +* since: v1.45 +- `now` <[int]|[Date]> + +Install fake timers with the specified unix epoch (default: 0). + +### option: Clock.install.toFake +* since: v1.45 +- `toFake` <[Array]<[FakeMethod]<"setTimeout"|"clearTimeout"|"setInterval"|"clearInterval"|"Date"|"requestAnimationFrame"|"cancelAnimationFrame"|"requestIdleCallback"|"cancelIdleCallback"|"performance">>> + +An array with names of global methods and APIs to fake. For instance, `await page.clock.install({ toFake: ['setTimeout'] })` will fake only `setTimeout()`. +By default, `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval` and `Date` are faked. + +### option: Clock.install.loopLimit +* since: v1.45 +- `loopLimit` <[int]> + +The maximum number of timers that will be run when calling [`method: Clock.runAll`]. Defaults to `1000`. + +### option: Clock.install.shouldAdvanceTime +* since: v1.45 +- `shouldAdvanceTime` <[boolean]> + +Tells `@sinonjs/fake-timers` to increment mocked time automatically based on the real system time shift (e.g., the mocked time will be incremented by +20ms for every 20ms change in the real system time). Defaults to `false`. + +### option: Clock.install.advanceTimeDelta +* since: v1.45 +- `advanceTimeDelta` <[int]> + +Relevant only when using with [`option: shouldAdvanceTime`]. Increment mocked time by advanceTimeDelta ms every advanceTimeDelta ms change +in the real system time (default: 20). + +## async method: Clock.jump +* since: v1.45 + +Advance the clock by jumping forward in time, firing callbacks at most once. Returns fake milliseconds since the unix epoch. +This can be used to simulate the JS engine (such as a browser) being put to sleep and resumed later, skipping intermediary timers. + +### param: Clock.jump.time +* since: v1.45 +- `time` <[int]|[string]> + +Time may be the number of milliseconds to advance the clock by or a human-readable string. Valid string formats are "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten seconds. + + +## async method: Clock.runAll +* since: v1.45 +- returns: <[int]> Fake milliseconds since the unix epoch. + +Runs all pending timers until there are none remaining. If new timers are added while it is executing they will be run as well. +This makes it easier to run asynchronous tests to completion without worrying about the number of timers they use, or the delays in those timers. +It runs a maximum of [`option: loopLimit`] times after which it assumes there is an infinite loop of timers and throws an error. + + +## async method: Clock.runToLast +* since: v1.45 +- returns: <[int]> Fake milliseconds since the unix epoch. + +This takes note of the last scheduled timer when it is run, and advances the clock to that time firing callbacks as necessary. +If new timers are added while it is executing they will be run only if they would occur before this time. +This is useful when you want to run a test to completion, but the test recursively sets timers that would cause runAll to trigger an infinite loop warning. + + +## async method: Clock.tick +* since: v1.45 +- returns: <[int]> Fake milliseconds since the unix epoch. + +Advance the clock, firing callbacks if necessary. Returns fake milliseconds since the unix epoch. + +### param: Clock.tick.time +* since: v1.45 +- `time` <[int]|[string]> + +Time may be the number of milliseconds to advance the clock by or a human-readable string. Valid string formats are "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten seconds. diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index e0ee157045..b4ee91eb1b 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -151,6 +151,12 @@ page.Load += PageLoadHandler; page.Load -= PageLoadHandler; ``` +## property: Page.clock +* since: v1.45 +- type: <[Clock]> + +Playwright is using [@sinonjs/fake-timers](https://github.com/sinonjs/fake-timers) to fake timers and clock. + ## event: Page.close * since: v1.8 - argument: <[Page]> diff --git a/packages/playwright-core/src/client/api.ts b/packages/playwright-core/src/client/api.ts index b41a85b854..6eab70e159 100644 --- a/packages/playwright-core/src/client/api.ts +++ b/packages/playwright-core/src/client/api.ts @@ -20,6 +20,7 @@ export { Browser } from './browser'; export { BrowserContext } from './browserContext'; export type { BrowserServer } from './browserType'; export { BrowserType } from './browserType'; +export { Clock } from './clock'; export { ConsoleMessage } from './consoleMessage'; export { Coverage } from './coverage'; export { Dialog } from './dialog'; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 3f5640789e..fcc17c4231 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -44,6 +44,7 @@ import { ConsoleMessage } from './consoleMessage'; import { Dialog } from './dialog'; import { WebError } from './webError'; import { TargetClosedError, parseError } from './errors'; +import { Clock } from './clock'; export class BrowserContext extends ChannelOwner implements api.BrowserContext { _pages = new Set(); @@ -58,6 +59,8 @@ export class BrowserContext extends ChannelOwner readonly request: APIRequestContext; readonly tracing: Tracing; + readonly clock: Clock; + readonly _backgroundPages = new Set(); readonly _serviceWorkers = new Set(); readonly _isChromium: boolean; @@ -82,6 +85,7 @@ export class BrowserContext extends ChannelOwner this._isChromium = this._browser?._name === 'chromium'; this.tracing = Tracing.from(initializer.tracing); this.request = APIRequestContext.from(initializer.requestContext); + this.clock = new Clock(this); this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding))); this._channel.on('close', () => this._onClose()); diff --git a/packages/playwright-core/src/client/clock.ts b/packages/playwright-core/src/client/clock.ts new file mode 100644 index 0000000000..8fd9e359d8 --- /dev/null +++ b/packages/playwright-core/src/client/clock.ts @@ -0,0 +1,57 @@ +/** + * 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 * as api from '../../types/types'; +import type * as channels from '@protocol/channels'; +import type { BrowserContext } from './browserContext'; + +export class Clock implements api.Clock { + private _browserContext: BrowserContext; + + constructor(browserContext: BrowserContext) { + this._browserContext = browserContext; + } + + async install(options?: Omit & { now?: number | Date }) { + const now = options && options.now ? (options.now instanceof Date ? options.now.getTime() : options.now) : undefined; + await this._browserContext._channel.clockInstall({ ...options, now }); + } + + async jump(time: number | string) { + await this._browserContext._channel.clockJump({ + timeNumber: typeof time === 'number' ? time : undefined, + timeString: typeof time === 'string' ? time : undefined + }); + } + + async runAll(): Promise { + const result = await this._browserContext._channel.clockRunAll(); + return result.fakeTime; + } + + async runToLast(): Promise { + const result = await this._browserContext._channel.clockRunToLast(); + return result.fakeTime; + } + + async tick(time: number | string): Promise { + const result = await this._browserContext._channel.clockTick({ + timeNumber: typeof time === 'number' ? time : undefined, + timeString: typeof time === 'string' ? time : undefined + }); + return result.fakeTime; + } +} diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 58f5758188..84848e433d 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -49,6 +49,7 @@ import { Video } from './video'; import { Waiter } from './waiter'; import { Worker } from './worker'; import { HarRouter } from './harRouter'; +import type { Clock } from './clock'; type PDFOptions = Omit & { width?: string | number, @@ -87,6 +88,8 @@ export class Page extends ChannelOwner implements api.Page readonly mouse: Mouse; readonly request: APIRequestContext; readonly touchscreen: Touchscreen; + readonly clock: Clock; + readonly _bindings = new Map any>(); readonly _timeoutSettings: TimeoutSettings; @@ -116,6 +119,7 @@ export class Page extends ChannelOwner implements api.Page this.mouse = new Mouse(this); this.request = this._browserContext.request; this.touchscreen = new Touchscreen(this); + this.clock = this._browserContext.clock; this._mainFrame = Frame.from(initializer.mainFrame); this._mainFrame._page = this; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 1cc57fb5a7..4d505069e1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -963,6 +963,34 @@ scheme.BrowserContextUpdateSubscriptionParams = tObject({ enabled: tBoolean, }); scheme.BrowserContextUpdateSubscriptionResult = tOptional(tObject({})); +scheme.BrowserContextClockInstallParams = tObject({ + now: tOptional(tNumber), + toFake: tOptional(tArray(tString)), + loopLimit: tOptional(tNumber), + shouldAdvanceTime: tOptional(tBoolean), + advanceTimeDelta: tOptional(tNumber), +}); +scheme.BrowserContextClockInstallResult = tOptional(tObject({})); +scheme.BrowserContextClockJumpParams = tObject({ + timeNumber: tOptional(tNumber), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockJumpResult = tOptional(tObject({})); +scheme.BrowserContextClockRunAllParams = tOptional(tObject({})); +scheme.BrowserContextClockRunAllResult = tObject({ + fakeTime: tNumber, +}); +scheme.BrowserContextClockRunToLastParams = tOptional(tObject({})); +scheme.BrowserContextClockRunToLastResult = tObject({ + fakeTime: tNumber, +}); +scheme.BrowserContextClockTickParams = tObject({ + timeNumber: tOptional(tNumber), + timeString: tOptional(tString), +}); +scheme.BrowserContextClockTickResult = tObject({ + fakeTime: tNumber, +}); scheme.PageInitializer = tObject({ mainFrame: tChannel(['Frame']), viewportSize: tOptional(tObject({ diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index cac7071a23..c1d70372a2 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -41,6 +41,7 @@ import { Recorder } from './recorder'; import * as consoleApiSource from '../generated/consoleApiSource'; import { BrowserContextAPIRequestContext } from './fetch'; import type { Artifact } from './artifact'; +import { Clock } from './clock'; export abstract class BrowserContext extends SdkObject { static Events = { @@ -87,6 +88,7 @@ export abstract class BrowserContext extends SdkObject { private _routesInFlight = new Set(); private _debugger!: Debugger; _closeReason: string | undefined; + readonly clock: Clock; constructor(browser: Browser, options: channels.BrowserNewContextParams, browserContextId: string | undefined) { super(browser, 'browser-context'); @@ -103,6 +105,7 @@ export abstract class BrowserContext extends SdkObject { this._harRecorders.set('', new HarRecorder(this, null, this._options.recordHar)); this.tracing = new Tracing(this, browser.options.tracesDir); + this.clock = new Clock(this); } isPersistentContext(): boolean { diff --git a/packages/playwright-core/src/server/clock.ts b/packages/playwright-core/src/server/clock.ts new file mode 100644 index 0000000000..e0ce1b64ff --- /dev/null +++ b/packages/playwright-core/src/server/clock.ts @@ -0,0 +1,79 @@ +/** + * 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 * as channels from '@protocol/channels'; +import type { BrowserContext } from './browserContext'; +import * as fakeTimersSource from '../generated/fakeTimersSource'; + +export class Clock { + private _browserContext: BrowserContext; + private _installed = false; + + constructor(browserContext: BrowserContext) { + this._browserContext = browserContext; + } + + async install(params: channels.BrowserContextClockInstallOptions) { + if (this._installed) + throw new Error('Cannot install more than one clock per context'); + this._installed = true; + const script = `(() => { + const module = {}; + ${fakeTimersSource.source} + globalThis.__pwFakeTimers = (module.exports.install())(${JSON.stringify(params)}); + })();`; + await this._addAndEvaluate(script); + } + + async jump(time: number | string) { + this._assertInstalled(); + await this._addAndEvaluate(`globalThis.__pwFakeTimers.jump(${JSON.stringify(time)}); 0`); + } + + async runAll(): Promise { + this._assertInstalled(); + await this._browserContext.addInitScript(`globalThis.__pwFakeTimers.runAll()`); + return await this._evaluateInFrames(`globalThis.__pwFakeTimers.runAllAsync()`); + } + + async runToLast(): Promise { + this._assertInstalled(); + await this._browserContext.addInitScript(`globalThis.__pwFakeTimers.runToLast()`); + return await this._evaluateInFrames(`globalThis.__pwFakeTimers.runToLastAsync()`); + } + + async tick(time: number | string): Promise { + this._assertInstalled(); + await this._browserContext.addInitScript(`globalThis.__pwFakeTimers.tick(${JSON.stringify(time)})`); + return await this._evaluateInFrames(`globalThis.__pwFakeTimers.tickAsync(${JSON.stringify(time)})`); + } + + private async _addAndEvaluate(script: string) { + await this._browserContext.addInitScript(script); + return await this._evaluateInFrames(script); + } + + private async _evaluateInFrames(script: string) { + const frames = this._browserContext.pages().map(page => page.frames()).flat(); + const results = await Promise.all(frames.map(frame => frame.evaluateExpression(script))); + return results[0]; + } + + private _assertInstalled() { + if (!this._installed) + throw new Error('Clock is not installed'); + } +} diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index b74a02ae25..741e49a456 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -312,6 +312,26 @@ export class BrowserContextDispatcher extends Dispatcher { + await this._context.clock.install(params); + } + + async clockJump(params: channels.BrowserContextClockJumpParams, metadata?: CallMetadata | undefined): Promise { + await this._context.clock.jump(params.timeString || params.timeNumber || 0); + } + + async clockRunAll(params: channels.BrowserContextClockRunAllParams, metadata?: CallMetadata | undefined): Promise { + return { fakeTime: await this._context.clock.runAll() }; + } + + async clockRunToLast(params: channels.BrowserContextClockRunToLastParams, metadata?: CallMetadata | undefined): Promise { + return { fakeTime: await this._context.clock.runToLast() }; + } + + async clockTick(params: channels.BrowserContextClockTickParams, metadata?: CallMetadata | undefined): Promise { + return { fakeTime: await this._context.clock.tick(params.timeString || params.timeNumber || 0) }; + } + async updateSubscription(params: channels.BrowserContextUpdateSubscriptionParams): Promise { if (params.enabled) this._subscriptions.add(params.event); diff --git a/packages/playwright-core/src/server/injected/DEPS.list b/packages/playwright-core/src/server/injected/DEPS.list index 1786754242..32da641c93 100644 --- a/packages/playwright-core/src/server/injected/DEPS.list +++ b/packages/playwright-core/src/server/injected/DEPS.list @@ -1,4 +1,7 @@ # Files in this folder are used in browser environment, they can only depend on isomorphic files. [*] ../isomorphic/ -../../utils/isomorphic \ No newline at end of file +../../utils/isomorphic + +[fakeTimers.ts] +../../third_party/fake-timers-src diff --git a/packages/playwright-core/src/server/injected/fakeTimers.ts b/packages/playwright-core/src/server/injected/fakeTimers.ts new file mode 100644 index 0000000000..3e8b1ab05b --- /dev/null +++ b/packages/playwright-core/src/server/injected/fakeTimers.ts @@ -0,0 +1,23 @@ +/** + * 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. + */ + +// @ts-ignore +import SinonFakeTimers from '../../third_party/fake-timers-src'; +import type * as channels from '@protocol/channels'; + +export function install(params: channels.BrowserContextClockInstallOptions) { + return SinonFakeTimers.install(params); +} diff --git a/packages/playwright-core/src/third_party/fake-timers-src.js b/packages/playwright-core/src/third_party/fake-timers-src.js new file mode 100644 index 0000000000..9602123052 --- /dev/null +++ b/packages/playwright-core/src/third_party/fake-timers-src.js @@ -0,0 +1,1776 @@ +/* + * Copyright (c) 2010-2014, Christian Johansen, christian@cjohansen.no. All rights reserved. + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +/ + +"use strict"; + +/* + * Local modifications: + * - removed global.process-related code. + * - removed require("@sinonjs/commons") dependency. + */ + +/** + * @typedef {object} IdleDeadline + * @property {boolean} didTimeout - whether or not the callback was called before reaching the optional timeout + * @property {function():number} timeRemaining - a floating-point value providing an estimate of the number of milliseconds remaining in the current idle period + */ + +/** + * Queues a function to be called during a browser's idle periods + * + * @callback RequestIdleCallback + * @param {function(IdleDeadline)} callback + * @param {{timeout: number}} options - an options object + * @returns {number} the id + */ + +/** + * @callback NextTick + * @param {VoidVarArgsFunc} callback - the callback to run + * @param {...*} args - optional arguments to call the callback with + * @returns {void} + */ + +/** + * @callback SetImmediate + * @param {VoidVarArgsFunc} callback - the callback to run + * @param {...*} args - optional arguments to call the callback with + * @returns {NodeImmediate} + */ + +/** + * @callback VoidVarArgsFunc + * @param {...*} callback - the callback to run + * @returns {void} + */ + +/** + * @typedef RequestAnimationFrame + * @property {function(number):void} requestAnimationFrame + * @returns {number} - the id + */ + +/** + * @typedef Performance + * @property {function(): number} now + */ + +/* eslint-disable jsdoc/require-property-description */ +/** + * @typedef {object} Clock + * @property {number} now - the current time + * @property {Date} Date - the Date constructor + * @property {number} loopLimit - the maximum number of timers before assuming an infinite loop + * @property {RequestIdleCallback} requestIdleCallback + * @property {function(number):void} cancelIdleCallback + * @property {setTimeout} setTimeout + * @property {clearTimeout} clearTimeout + * @property {NextTick} nextTick + * @property {queueMicrotask} queueMicrotask + * @property {setInterval} setInterval + * @property {clearInterval} clearInterval + * @property {SetImmediate} setImmediate + * @property {function(NodeImmediate):void} clearImmediate + * @property {function():number} countTimers + * @property {RequestAnimationFrame} requestAnimationFrame + * @property {function(number):void} cancelAnimationFrame + * @property {function():void} runMicrotasks + * @property {function(string | number): number} tick + * @property {function(string | number): Promise} tickAsync + * @property {function(): number} next + * @property {function(): Promise} nextAsync + * @property {function(): number} runAll + * @property {function(): number} runToFrame + * @property {function(): Promise} runAllAsync + * @property {function(): number} runToLast + * @property {function(): Promise} runToLastAsync + * @property {function(): void} reset + * @property {function(number | Date): void} setSystemTime + * @property {function(number): void} jump + * @property {Performance} performance + * @property {function(number[]): number[]} hrtime - process.hrtime (legacy) + * @property {function(): void} uninstall Uninstall the clock. + * @property {Function[]} methods - the methods that are faked + * @property {boolean} [shouldClearNativeTimers] inherited from config + */ +/* eslint-enable jsdoc/require-property-description */ + +/** + * Configuration object for the `install` method. + * + * @typedef {object} Config + * @property {number|Date} [now] a number (in milliseconds) or a Date object (default epoch) + * @property {string[]} [toFake] names of the methods that should be faked. + * @property {number} [loopLimit] the maximum number of timers that will be run when calling runAll() + * @property {boolean} [shouldAdvanceTime] tells FakeTimers to increment mocked time automatically (default false) + * @property {number} [advanceTimeDelta] increment mocked time every <> ms (default: 20ms) + * @property {boolean} [shouldClearNativeTimers] forwards clear timer calls to native functions if they are not fakes (default: false) + */ + +/* eslint-disable jsdoc/require-property-description */ +/** + * The internal structure to describe a scheduled fake timer + * + * @typedef {object} Timer + * @property {Function} func + * @property {*[]} args + * @property {number} delay + * @property {number} callAt + * @property {number} createdAt + * @property {boolean} immediate + * @property {number} id + * @property {Error} [error] + */ + +/** + * A Node timer + * + * @typedef {object} NodeImmediate + * @property {function(): boolean} hasRef + * @property {function(): NodeImmediate} ref + * @property {function(): NodeImmediate} unref + */ +/* eslint-enable jsdoc/require-property-description */ + +/* eslint-disable complexity */ + +/** + * Mocks available features in the specified global namespace. + * + * @param {*} _global Namespace to mock (e.g. `window`) + * @returns {FakeTimers} + */ +function withGlobal(_global) { + const maxTimeout = Math.pow(2, 31) - 1; //see https://heycam.github.io/webidl/#abstract-opdef-converttoint + const idCounterStart = 1e12; // arbitrarily large number to avoid collisions with native timer IDs + const NOOP = function () { + return undefined; + }; + const NOOP_ARRAY = function () { + return []; + }; + const timeoutResult = _global.setTimeout(NOOP, 0); + const addTimerReturnsObject = typeof timeoutResult === "object"; + const performancePresent = + _global.performance && typeof _global.performance.now === "function"; + const hasPerformancePrototype = + _global.Performance && + (typeof _global.Performance).match(/^(function|object)$/); + const hasPerformanceConstructorPrototype = + _global.performance && + _global.performance.constructor && + _global.performance.constructor.prototype; + const queueMicrotaskPresent = _global.hasOwnProperty("queueMicrotask"); + const requestAnimationFramePresent = + _global.requestAnimationFrame && + typeof _global.requestAnimationFrame === "function"; + const cancelAnimationFramePresent = + _global.cancelAnimationFrame && + typeof _global.cancelAnimationFrame === "function"; + const requestIdleCallbackPresent = + _global.requestIdleCallback && + typeof _global.requestIdleCallback === "function"; + const cancelIdleCallbackPresent = + _global.cancelIdleCallback && + typeof _global.cancelIdleCallback === "function"; + const setImmediatePresent = + _global.setImmediate && typeof _global.setImmediate === "function"; + const intlPresent = _global.Intl && typeof _global.Intl === "object"; + + _global.clearTimeout(timeoutResult); + + const NativeDate = _global.Date; + const NativeIntl = _global.Intl; + let uniqueTimerId = idCounterStart; + + /** + * @param {number} num + * @returns {boolean} + */ + function isNumberFinite(num) { + if (Number.isFinite) { + return Number.isFinite(num); + } + + return isFinite(num); + } + + let isNearInfiniteLimit = false; + + /** + * @param {Clock} clock + * @param {number} i + */ + function checkIsNearInfiniteLimit(clock, i) { + if (clock.loopLimit && i === clock.loopLimit - 1) { + isNearInfiniteLimit = true; + } + } + + /** + * + */ + function resetIsNearInfiniteLimit() { + isNearInfiniteLimit = false; + } + + /** + * Parse strings like "01:10:00" (meaning 1 hour, 10 minutes, 0 seconds) into + * number of milliseconds. This is used to support human-readable strings passed + * to clock.tick() + * + * @param {string} str + * @returns {number} + */ + function parseTime(str) { + if (!str) { + return 0; + } + + const strings = str.split(":"); + const l = strings.length; + let i = l; + let ms = 0; + let parsed; + + if (l > 3 || !/^(\d\d:){0,2}\d\d?$/.test(str)) { + throw new Error( + "tick only understands numbers, 'm:s' and 'h:m:s'. Each part must be two digits", + ); + } + + while (i--) { + parsed = parseInt(strings[i], 10); + + if (parsed >= 60) { + throw new Error(`Invalid time ${str}`); + } + + ms += parsed * Math.pow(60, l - i - 1); + } + + return ms * 1000; + } + + /** + * Get the decimal part of the millisecond value as nanoseconds + * + * @param {number} msFloat the number of milliseconds + * @returns {number} an integer number of nanoseconds in the range [0,1e6) + * + * Example: nanoRemainer(123.456789) -> 456789 + */ + function nanoRemainder(msFloat) { + const modulo = 1e6; + const remainder = (msFloat * 1e6) % modulo; + const positiveRemainder = + remainder < 0 ? remainder + modulo : remainder; + + return Math.floor(positiveRemainder); + } + + /** + * Used to grok the `now` parameter to createClock. + * + * @param {Date|number} epoch the system time + * @returns {number} + */ + function getEpoch(epoch) { + if (!epoch) { + return 0; + } + if (typeof epoch.getTime === "function") { + return epoch.getTime(); + } + if (typeof epoch === "number") { + return epoch; + } + throw new TypeError("now should be milliseconds since UNIX epoch"); + } + + /** + * @param {number} from + * @param {number} to + * @param {Timer} timer + * @returns {boolean} + */ + function inRange(from, to, timer) { + return timer && timer.callAt >= from && timer.callAt <= to; + } + + /** + * @param {Clock} clock + * @param {Timer} job + */ + function getInfiniteLoopError(clock, job) { + const infiniteLoopError = new Error( + `Aborting after running ${clock.loopLimit} timers, assuming an infinite loop!`, + ); + + if (!job.error) { + return infiniteLoopError; + } + + // pattern never matched in Node + const computedTargetPattern = /target\.*[<|(|[].*?[>|\]|)]\s*/; + let clockMethodPattern = new RegExp( + String(Object.keys(clock).join("|")), + ); + + if (addTimerReturnsObject) { + // node.js environment + clockMethodPattern = new RegExp( + `\\s+at (Object\\.)?(?:${Object.keys(clock).join("|")})\\s+`, + ); + } + + let matchedLineIndex = -1; + job.error.stack.split("\n").some(function (line, i) { + // If we've matched a computed target line (e.g. setTimeout) then we + // don't need to look any further. Return true to stop iterating. + const matchedComputedTarget = line.match(computedTargetPattern); + /* istanbul ignore if */ + if (matchedComputedTarget) { + matchedLineIndex = i; + return true; + } + + // If we've matched a clock method line, then there may still be + // others further down the trace. Return false to keep iterating. + const matchedClockMethod = line.match(clockMethodPattern); + if (matchedClockMethod) { + matchedLineIndex = i; + return false; + } + + // If we haven't matched anything on this line, but we matched + // previously and set the matched line index, then we can stop. + // If we haven't matched previously, then we should keep iterating. + return matchedLineIndex >= 0; + }); + + const stack = `${infiniteLoopError}\n${job.type || "Microtask"} - ${ + job.func.name || "anonymous" + }\n${job.error.stack + .split("\n") + .slice(matchedLineIndex + 1) + .join("\n")}`; + + try { + Object.defineProperty(infiniteLoopError, "stack", { + value: stack, + }); + } catch (e) { + // noop + } + + return infiniteLoopError; + } + + /** + * @param {Date} target + * @param {Date} source + * @returns {Date} the target after modifications + */ + function mirrorDateProperties(target, source) { + let prop; + for (prop in source) { + if (source.hasOwnProperty(prop)) { + target[prop] = source[prop]; + } + } + + // set special now implementation + if (source.now) { + target.now = function now() { + return target.clock.now; + }; + } else { + delete target.now; + } + + // set special toSource implementation + if (source.toSource) { + target.toSource = function toSource() { + return source.toSource(); + }; + } else { + delete target.toSource; + } + + // set special toString implementation + target.toString = function toString() { + return source.toString(); + }; + + target.prototype = source.prototype; + target.parse = source.parse; + target.UTC = source.UTC; + target.prototype.toUTCString = source.prototype.toUTCString; + target.isFake = true; + + return target; + } + + //eslint-disable-next-line jsdoc/require-jsdoc + function createDate() { + /** + * @param {number} year + * @param {number} month + * @param {number} date + * @param {number} hour + * @param {number} minute + * @param {number} second + * @param {number} ms + * @returns {Date} + */ + function ClockDate(year, month, date, hour, minute, second, ms) { + // the Date constructor called as a function, ref Ecma-262 Edition 5.1, section 15.9.2. + // This remains so in the 10th edition of 2019 as well. + if (!(this instanceof ClockDate)) { + return new NativeDate(ClockDate.clock.now).toString(); + } + + // if Date is called as a constructor with 'new' keyword + // Defensive and verbose to avoid potential harm in passing + // explicit undefined when user does not pass argument + switch (arguments.length) { + case 0: + return new NativeDate(ClockDate.clock.now); + case 1: + return new NativeDate(year); + case 2: + return new NativeDate(year, month); + case 3: + return new NativeDate(year, month, date); + case 4: + return new NativeDate(year, month, date, hour); + case 5: + return new NativeDate(year, month, date, hour, minute); + case 6: + return new NativeDate( + year, + month, + date, + hour, + minute, + second, + ); + default: + return new NativeDate( + year, + month, + date, + hour, + minute, + second, + ms, + ); + } + } + + return mirrorDateProperties(ClockDate, NativeDate); + } + + /** + * Mirror Intl by default on our fake implementation + * + * Most of the properties are the original native ones, + * but we need to take control of those that have a + * dependency on the current clock. + * + * @returns {object} the partly fake Intl implementation + */ + function createIntl() { + const ClockIntl = {}; + /* + * All properties of Intl are non-enumerable, so we need + * to do a bit of work to get them out. + */ + Object.getOwnPropertyNames(NativeIntl).forEach( + (property) => (ClockIntl[property] = NativeIntl[property]), + ); + + ClockIntl.DateTimeFormat = function (...args) { + const realFormatter = new NativeIntl.DateTimeFormat(...args); + const formatter = {}; + + ["formatRange", "formatRangeToParts", "resolvedOptions"].forEach( + (method) => { + formatter[method] = + realFormatter[method].bind(realFormatter); + }, + ); + + ["format", "formatToParts"].forEach((method) => { + formatter[method] = function (date) { + return realFormatter[method](date || ClockIntl.clock.now); + }; + }); + + return formatter; + }; + + ClockIntl.DateTimeFormat.prototype = Object.create( + NativeIntl.DateTimeFormat.prototype, + ); + + ClockIntl.DateTimeFormat.supportedLocalesOf = + NativeIntl.DateTimeFormat.supportedLocalesOf; + + return ClockIntl; + } + + //eslint-disable-next-line jsdoc/require-jsdoc + function enqueueJob(clock, job) { + // enqueues a microtick-deferred task - ecma262/#sec-enqueuejob + if (!clock.jobs) { + clock.jobs = []; + } + clock.jobs.push(job); + } + + //eslint-disable-next-line jsdoc/require-jsdoc + function runJobs(clock) { + // runs all microtick-deferred tasks - ecma262/#sec-runjobs + if (!clock.jobs) { + return; + } + for (let i = 0; i < clock.jobs.length; i++) { + const job = clock.jobs[i]; + job.func.apply(null, job.args); + + checkIsNearInfiniteLimit(clock, i); + if (clock.loopLimit && i > clock.loopLimit) { + throw getInfiniteLoopError(clock, job); + } + } + resetIsNearInfiniteLimit(); + clock.jobs = []; + } + + /** + * @param {Clock} clock + * @param {Timer} timer + * @returns {number} id of the created timer + */ + function addTimer(clock, timer) { + if (timer.func === undefined) { + throw new Error("Callback must be provided to timer calls"); + } + + if (addTimerReturnsObject) { + // Node.js environment + if (typeof timer.func !== "function") { + throw new TypeError( + `[ERR_INVALID_CALLBACK]: Callback must be a function. Received ${ + timer.func + } of type ${typeof timer.func}`, + ); + } + } + + if (isNearInfiniteLimit) { + timer.error = new Error(); + } + + timer.type = timer.immediate ? "Immediate" : "Timeout"; + + if (timer.hasOwnProperty("delay")) { + if (typeof timer.delay !== "number") { + timer.delay = parseInt(timer.delay, 10); + } + + if (!isNumberFinite(timer.delay)) { + timer.delay = 0; + } + timer.delay = timer.delay > maxTimeout ? 1 : timer.delay; + timer.delay = Math.max(0, timer.delay); + } + + if (timer.hasOwnProperty("interval")) { + timer.type = "Interval"; + timer.interval = timer.interval > maxTimeout ? 1 : timer.interval; + } + + if (timer.hasOwnProperty("animation")) { + timer.type = "AnimationFrame"; + timer.animation = true; + } + + if (timer.hasOwnProperty("idleCallback")) { + timer.type = "IdleCallback"; + timer.idleCallback = true; + } + + if (!clock.timers) { + clock.timers = {}; + } + + timer.id = uniqueTimerId++; + timer.createdAt = clock.now; + timer.callAt = + clock.now + (parseInt(timer.delay) || (clock.duringTick ? 1 : 0)); + + clock.timers[timer.id] = timer; + + if (addTimerReturnsObject) { + const res = { + refed: true, + ref: function () { + this.refed = true; + return res; + }, + unref: function () { + this.refed = false; + return res; + }, + hasRef: function () { + return this.refed; + }, + refresh: function () { + timer.callAt = + clock.now + + (parseInt(timer.delay) || (clock.duringTick ? 1 : 0)); + + // it _might_ have been removed, but if not the assignment is perfectly fine + clock.timers[timer.id] = timer; + + return res; + }, + [Symbol.toPrimitive]: function () { + return timer.id; + }, + }; + return res; + } + + return timer.id; + } + + /* eslint consistent-return: "off" */ + /** + * Timer comparitor + * + * @param {Timer} a + * @param {Timer} b + * @returns {number} + */ + function compareTimers(a, b) { + // Sort first by absolute timing + if (a.callAt < b.callAt) { + return -1; + } + if (a.callAt > b.callAt) { + return 1; + } + + // Sort next by immediate, immediate timers take precedence + if (a.immediate && !b.immediate) { + return -1; + } + if (!a.immediate && b.immediate) { + return 1; + } + + // Sort next by creation time, earlier-created timers take precedence + if (a.createdAt < b.createdAt) { + return -1; + } + if (a.createdAt > b.createdAt) { + return 1; + } + + // Sort next by id, lower-id timers take precedence + if (a.id < b.id) { + return -1; + } + if (a.id > b.id) { + return 1; + } + + // As timer ids are unique, no fallback `0` is necessary + } + + /** + * @param {Clock} clock + * @param {number} from + * @param {number} to + * @returns {Timer} + */ + function firstTimerInRange(clock, from, to) { + const timers = clock.timers; + let timer = null; + let id, isInRange; + + for (id in timers) { + if (timers.hasOwnProperty(id)) { + isInRange = inRange(from, to, timers[id]); + + if ( + isInRange && + (!timer || compareTimers(timer, timers[id]) === 1) + ) { + timer = timers[id]; + } + } + } + + return timer; + } + + /** + * @param {Clock} clock + * @returns {Timer} + */ + function firstTimer(clock) { + const timers = clock.timers; + let timer = null; + let id; + + for (id in timers) { + if (timers.hasOwnProperty(id)) { + if (!timer || compareTimers(timer, timers[id]) === 1) { + timer = timers[id]; + } + } + } + + return timer; + } + + /** + * @param {Clock} clock + * @returns {Timer} + */ + function lastTimer(clock) { + const timers = clock.timers; + let timer = null; + let id; + + for (id in timers) { + if (timers.hasOwnProperty(id)) { + if (!timer || compareTimers(timer, timers[id]) === -1) { + timer = timers[id]; + } + } + } + + return timer; + } + + /** + * @param {Clock} clock + * @param {Timer} timer + */ + function callTimer(clock, timer) { + if (typeof timer.interval === "number") { + clock.timers[timer.id].callAt += timer.interval; + } else { + delete clock.timers[timer.id]; + } + + if (typeof timer.func === "function") { + timer.func.apply(null, timer.args); + } else { + /* eslint no-eval: "off" */ + const eval2 = eval; + (function () { + eval2(timer.func); + })(); + } + } + + /** + * Gets clear handler name for a given timer type + * + * @param {string} ttype + */ + function getClearHandler(ttype) { + if (ttype === "IdleCallback" || ttype === "AnimationFrame") { + return `cancel${ttype}`; + } + return `clear${ttype}`; + } + + /** + * Gets schedule handler name for a given timer type + * + * @param {string} ttype + */ + function getScheduleHandler(ttype) { + if (ttype === "IdleCallback" || ttype === "AnimationFrame") { + return `request${ttype}`; + } + return `set${ttype}`; + } + + /** + * Creates an anonymous function to warn only once + */ + function createWarnOnce() { + let calls = 0; + return function (msg) { + // eslint-disable-next-line + !calls++ && console.warn(msg); + }; + } + const warnOnce = createWarnOnce(); + + /** + * @param {Clock} clock + * @param {number} timerId + * @param {string} ttype + */ + function clearTimer(clock, timerId, ttype) { + if (!timerId) { + // null appears to be allowed in most browsers, and appears to be + // relied upon by some libraries, like Bootstrap carousel + return; + } + + if (!clock.timers) { + clock.timers = {}; + } + + // in Node, the ID is stored as the primitive value for `Timeout` objects + // for `Immediate` objects, no ID exists, so it gets coerced to NaN + const id = Number(timerId); + + if (Number.isNaN(id) || id < idCounterStart) { + const handlerName = getClearHandler(ttype); + + if (clock.shouldClearNativeTimers === true) { + const nativeHandler = clock[`_${handlerName}`]; + return typeof nativeHandler === "function" + ? nativeHandler(timerId) + : undefined; + } + warnOnce( + `FakeTimers: ${handlerName} was invoked to clear a native timer instead of one created by this library.` + + "\nTo automatically clean-up native timers, use `shouldClearNativeTimers`.", + ); + } + + if (clock.timers.hasOwnProperty(id)) { + // check that the ID matches a timer of the correct type + const timer = clock.timers[id]; + if ( + timer.type === ttype || + (timer.type === "Timeout" && ttype === "Interval") || + (timer.type === "Interval" && ttype === "Timeout") + ) { + delete clock.timers[id]; + } else { + const clear = getClearHandler(ttype); + const schedule = getScheduleHandler(timer.type); + throw new Error( + `Cannot clear timer: timer created with ${schedule}() but cleared with ${clear}()`, + ); + } + } + } + + /** + * @param {Clock} clock + * @param {Config} config + * @returns {Timer[]} + */ + function uninstall(clock, config) { + let method, i, l; + const installedHrTime = "_hrtime"; + const installedNextTick = "_nextTick"; + + for (i = 0, l = clock.methods.length; i < l; i++) { + method = clock.methods[i]; + if (method === "performance") { + const originalPerfDescriptor = Object.getOwnPropertyDescriptor( + clock, + `_${method}`, + ); + if ( + originalPerfDescriptor && + originalPerfDescriptor.get && + !originalPerfDescriptor.set + ) { + Object.defineProperty( + _global, + method, + originalPerfDescriptor, + ); + } else if (originalPerfDescriptor.configurable) { + _global[method] = clock[`_${method}`]; + } + } else { + if (_global[method] && _global[method].hadOwnProperty) { + _global[method] = clock[`_${method}`]; + } else { + try { + delete _global[method]; + } catch (ignore) { + /* eslint no-empty: "off" */ + } + } + } + } + + if (config.shouldAdvanceTime === true) { + _global.clearInterval(clock.attachedInterval); + } + + // Prevent multiple executions which will completely remove these props + clock.methods = []; + + // return pending timers, to enable checking what timers remained on uninstall + if (!clock.timers) { + return []; + } + return Object.keys(clock.timers).map(function mapper(key) { + return clock.timers[key]; + }); + } + + /** + * @param {object} target the target containing the method to replace + * @param {string} method the keyname of the method on the target + * @param {Clock} clock + */ + function hijackMethod(target, method, clock) { + clock[method].hadOwnProperty = Object.prototype.hasOwnProperty.call( + target, + method, + ); + clock[`_${method}`] = target[method]; + + if (method === "Date") { + const date = mirrorDateProperties(clock[method], target[method]); + target[method] = date; + } else if (method === "Intl") { + target[method] = clock[method]; + } else if (method === "performance") { + const originalPerfDescriptor = Object.getOwnPropertyDescriptor( + target, + method, + ); + // JSDOM has a read only performance field so we have to save/copy it differently + if ( + originalPerfDescriptor && + originalPerfDescriptor.get && + !originalPerfDescriptor.set + ) { + Object.defineProperty( + clock, + `_${method}`, + originalPerfDescriptor, + ); + + const perfDescriptor = Object.getOwnPropertyDescriptor( + clock, + method, + ); + Object.defineProperty(target, method, perfDescriptor); + } else { + target[method] = clock[method]; + } + } else { + target[method] = function () { + return clock[method].apply(clock, arguments); + }; + + Object.defineProperties( + target[method], + Object.getOwnPropertyDescriptors(clock[method]), + ); + } + + target[method].clock = clock; + } + + /** + * @param {Clock} clock + * @param {number} advanceTimeDelta + */ + function doIntervalTick(clock, advanceTimeDelta) { + clock.tick(advanceTimeDelta); + } + + /** + * @typedef {object} Timers + * @property {setTimeout} setTimeout + * @property {clearTimeout} clearTimeout + * @property {setInterval} setInterval + * @property {clearInterval} clearInterval + * @property {Date} Date + * @property {Intl} Intl + * @property {SetImmediate=} setImmediate + * @property {function(NodeImmediate): void=} clearImmediate + * @property {function(number[]):number[]=} hrtime + * @property {NextTick=} nextTick + * @property {Performance=} performance + * @property {RequestAnimationFrame=} requestAnimationFrame + * @property {boolean=} queueMicrotask + * @property {function(number): void=} cancelAnimationFrame + * @property {RequestIdleCallback=} requestIdleCallback + * @property {function(number): void=} cancelIdleCallback + */ + + /** @type {Timers} */ + const timers = { + setTimeout: _global.setTimeout, + clearTimeout: _global.clearTimeout, + setInterval: _global.setInterval, + clearInterval: _global.clearInterval, + Date: _global.Date, + }; + + if (setImmediatePresent) { + timers.setImmediate = _global.setImmediate; + timers.clearImmediate = _global.clearImmediate; + } + + if (performancePresent) { + timers.performance = _global.performance; + } + + if (requestAnimationFramePresent) { + timers.requestAnimationFrame = _global.requestAnimationFrame; + } + + if (queueMicrotaskPresent) { + timers.queueMicrotask = true; + } + + if (cancelAnimationFramePresent) { + timers.cancelAnimationFrame = _global.cancelAnimationFrame; + } + + if (requestIdleCallbackPresent) { + timers.requestIdleCallback = _global.requestIdleCallback; + } + + if (cancelIdleCallbackPresent) { + timers.cancelIdleCallback = _global.cancelIdleCallback; + } + + if (intlPresent) { + timers.Intl = _global.Intl; + } + + const originalSetTimeout = _global.setImmediate || _global.setTimeout; + + /** + * @param {Date|number} [start] the system time - non-integer values are floored + * @param {number} [loopLimit] maximum number of timers that will be run when calling runAll() + * @returns {Clock} + */ + function createClock(start, loopLimit) { + // eslint-disable-next-line no-param-reassign + start = Math.floor(getEpoch(start)); + // eslint-disable-next-line no-param-reassign + loopLimit = loopLimit || 1000; + let nanos = 0; + const adjustedSystemTime = [0, 0]; // [millis, nanoremainder] + + if (NativeDate === undefined) { + throw new Error( + "The global scope doesn't have a `Date` object" + + " (see https://github.com/sinonjs/sinon/issues/1852#issuecomment-419622780)", + ); + } + + const clock = { + now: start, + Date: createDate(), + loopLimit: loopLimit, + }; + + clock.Date.clock = clock; + + //eslint-disable-next-line jsdoc/require-jsdoc + function getTimeToNextFrame() { + return 16 - ((clock.now - start) % 16); + } + + //eslint-disable-next-line jsdoc/require-jsdoc + function hrtime(prev) { + const millisSinceStart = clock.now - adjustedSystemTime[0] - start; + const secsSinceStart = Math.floor(millisSinceStart / 1000); + const remainderInNanos = + (millisSinceStart - secsSinceStart * 1e3) * 1e6 + + nanos - + adjustedSystemTime[1]; + + if (Array.isArray(prev)) { + if (prev[1] > 1e9) { + throw new TypeError( + "Number of nanoseconds can't exceed a billion", + ); + } + + const oldSecs = prev[0]; + let nanoDiff = remainderInNanos - prev[1]; + let secDiff = secsSinceStart - oldSecs; + + if (nanoDiff < 0) { + nanoDiff += 1e9; + secDiff -= 1; + } + + return [secDiff, nanoDiff]; + } + return [secsSinceStart, remainderInNanos]; + } + + /** + * A high resolution timestamp in milliseconds. + * + * @typedef {number} DOMHighResTimeStamp + */ + + /** + * performance.now() + * + * @returns {DOMHighResTimeStamp} + */ + function fakePerformanceNow() { + const hrt = hrtime(); + const millis = hrt[0] * 1000 + hrt[1] / 1e6; + return millis; + } + + if (intlPresent) { + clock.Intl = createIntl(); + clock.Intl.clock = clock; + } + + clock.requestIdleCallback = function requestIdleCallback( + func, + timeout, + ) { + let timeToNextIdlePeriod = 0; + + if (clock.countTimers() > 0) { + timeToNextIdlePeriod = 50; // const for now + } + + const result = addTimer(clock, { + func: func, + args: Array.prototype.slice.call(arguments, 2), + delay: + typeof timeout === "undefined" + ? timeToNextIdlePeriod + : Math.min(timeout, timeToNextIdlePeriod), + idleCallback: true, + }); + + return Number(result); + }; + + clock.cancelIdleCallback = function cancelIdleCallback(timerId) { + return clearTimer(clock, timerId, "IdleCallback"); + }; + + clock.setTimeout = function setTimeout(func, timeout) { + return addTimer(clock, { + func: func, + args: Array.prototype.slice.call(arguments, 2), + delay: timeout, + }); + }; + + clock.clearTimeout = function clearTimeout(timerId) { + return clearTimer(clock, timerId, "Timeout"); + }; + + clock.nextTick = function nextTick(func) { + return enqueueJob(clock, { + func: func, + args: Array.prototype.slice.call(arguments, 1), + error: isNearInfiniteLimit ? new Error() : null, + }); + }; + + clock.queueMicrotask = function queueMicrotask(func) { + return clock.nextTick(func); // explicitly drop additional arguments + }; + + clock.setInterval = function setInterval(func, timeout) { + // eslint-disable-next-line no-param-reassign + timeout = parseInt(timeout, 10); + return addTimer(clock, { + func: func, + args: Array.prototype.slice.call(arguments, 2), + delay: timeout, + interval: timeout, + }); + }; + + clock.clearInterval = function clearInterval(timerId) { + return clearTimer(clock, timerId, "Interval"); + }; + + if (setImmediatePresent) { + clock.setImmediate = function setImmediate(func) { + return addTimer(clock, { + func: func, + args: Array.prototype.slice.call(arguments, 1), + immediate: true, + }); + }; + + clock.clearImmediate = function clearImmediate(timerId) { + return clearTimer(clock, timerId, "Immediate"); + }; + } + + clock.countTimers = function countTimers() { + return ( + Object.keys(clock.timers || {}).length + + (clock.jobs || []).length + ); + }; + + clock.requestAnimationFrame = function requestAnimationFrame(func) { + const result = addTimer(clock, { + func: func, + delay: getTimeToNextFrame(), + get args() { + return [fakePerformanceNow()]; + }, + animation: true, + }); + + return Number(result); + }; + + clock.cancelAnimationFrame = function cancelAnimationFrame(timerId) { + return clearTimer(clock, timerId, "AnimationFrame"); + }; + + clock.runMicrotasks = function runMicrotasks() { + runJobs(clock); + }; + + /** + * @param {number|string} tickValue milliseconds or a string parseable by parseTime + * @param {boolean} isAsync + * @param {Function} resolve + * @param {Function} reject + * @returns {number|undefined} will return the new `now` value or nothing for async + */ + function doTick(tickValue, isAsync, resolve, reject) { + const msFloat = + typeof tickValue === "number" + ? tickValue + : parseTime(tickValue); + const ms = Math.floor(msFloat); + const remainder = nanoRemainder(msFloat); + let nanosTotal = nanos + remainder; + let tickTo = clock.now + ms; + + if (msFloat < 0) { + throw new TypeError("Negative ticks are not supported"); + } + + // adjust for positive overflow + if (nanosTotal >= 1e6) { + tickTo += 1; + nanosTotal -= 1e6; + } + + nanos = nanosTotal; + let tickFrom = clock.now; + let previous = clock.now; + // ESLint fails to detect this correctly + /* eslint-disable prefer-const */ + let timer, + firstException, + oldNow, + nextPromiseTick, + compensationCheck, + postTimerCall; + /* eslint-enable prefer-const */ + + clock.duringTick = true; + + // perform microtasks + oldNow = clock.now; + runJobs(clock); + if (oldNow !== clock.now) { + // compensate for any setSystemTime() call during microtask callback + tickFrom += clock.now - oldNow; + tickTo += clock.now - oldNow; + } + + //eslint-disable-next-line jsdoc/require-jsdoc + function doTickInner() { + // perform each timer in the requested range + timer = firstTimerInRange(clock, tickFrom, tickTo); + // eslint-disable-next-line no-unmodified-loop-condition + while (timer && tickFrom <= tickTo) { + if (clock.timers[timer.id]) { + tickFrom = timer.callAt; + clock.now = timer.callAt; + oldNow = clock.now; + try { + runJobs(clock); + callTimer(clock, timer); + } catch (e) { + firstException = firstException || e; + } + + if (isAsync) { + // finish up after native setImmediate callback to allow + // all native es6 promises to process their callbacks after + // each timer fires. + originalSetTimeout(nextPromiseTick); + return; + } + + compensationCheck(); + } + + postTimerCall(); + } + + // perform process.nextTick()s again + oldNow = clock.now; + runJobs(clock); + if (oldNow !== clock.now) { + // compensate for any setSystemTime() call during process.nextTick() callback + tickFrom += clock.now - oldNow; + tickTo += clock.now - oldNow; + } + clock.duringTick = false; + + // corner case: during runJobs new timers were scheduled which could be in the range [clock.now, tickTo] + timer = firstTimerInRange(clock, tickFrom, tickTo); + if (timer) { + try { + clock.tick(tickTo - clock.now); // do it all again - for the remainder of the requested range + } catch (e) { + firstException = firstException || e; + } + } else { + // no timers remaining in the requested range: move the clock all the way to the end + clock.now = tickTo; + + // update nanos + nanos = nanosTotal; + } + if (firstException) { + throw firstException; + } + + if (isAsync) { + resolve(clock.now); + } else { + return clock.now; + } + } + + nextPromiseTick = + isAsync && + function () { + try { + compensationCheck(); + postTimerCall(); + doTickInner(); + } catch (e) { + reject(e); + } + }; + + compensationCheck = function () { + // compensate for any setSystemTime() call during timer callback + if (oldNow !== clock.now) { + tickFrom += clock.now - oldNow; + tickTo += clock.now - oldNow; + previous += clock.now - oldNow; + } + }; + + postTimerCall = function () { + timer = firstTimerInRange(clock, previous, tickTo); + previous = tickFrom; + }; + + return doTickInner(); + } + + /** + * @param {string|number} tickValue number of milliseconds or a human-readable value like "01:11:15" + * @returns {number} will return the new `now` value + */ + clock.tick = function tick(tickValue) { + return doTick(tickValue, false); + }; + + if (typeof _global.Promise !== "undefined") { + /** + * @param {string|number} tickValue number of milliseconds or a human-readable value like "01:11:15" + * @returns {Promise} + */ + clock.tickAsync = function tickAsync(tickValue) { + return new _global.Promise(function (resolve, reject) { + originalSetTimeout(function () { + try { + doTick(tickValue, true, resolve, reject); + } catch (e) { + reject(e); + } + }); + }); + }; + } + + clock.next = function next() { + runJobs(clock); + const timer = firstTimer(clock); + if (!timer) { + return clock.now; + } + + clock.duringTick = true; + try { + clock.now = timer.callAt; + callTimer(clock, timer); + runJobs(clock); + return clock.now; + } finally { + clock.duringTick = false; + } + }; + + if (typeof _global.Promise !== "undefined") { + clock.nextAsync = function nextAsync() { + return new _global.Promise(function (resolve, reject) { + originalSetTimeout(function () { + try { + const timer = firstTimer(clock); + if (!timer) { + resolve(clock.now); + return; + } + + let err; + clock.duringTick = true; + clock.now = timer.callAt; + try { + callTimer(clock, timer); + } catch (e) { + err = e; + } + clock.duringTick = false; + + originalSetTimeout(function () { + if (err) { + reject(err); + } else { + resolve(clock.now); + } + }); + } catch (e) { + reject(e); + } + }); + }); + }; + } + + clock.runAll = function runAll() { + let numTimers, i; + runJobs(clock); + for (i = 0; i < clock.loopLimit; i++) { + if (!clock.timers) { + resetIsNearInfiniteLimit(); + return clock.now; + } + + numTimers = Object.keys(clock.timers).length; + if (numTimers === 0) { + resetIsNearInfiniteLimit(); + return clock.now; + } + + clock.next(); + checkIsNearInfiniteLimit(clock, i); + } + + const excessJob = firstTimer(clock); + throw getInfiniteLoopError(clock, excessJob); + }; + + clock.runToFrame = function runToFrame() { + return clock.tick(getTimeToNextFrame()); + }; + + if (typeof _global.Promise !== "undefined") { + clock.runAllAsync = function runAllAsync() { + return new _global.Promise(function (resolve, reject) { + let i = 0; + /** + * + */ + function doRun() { + originalSetTimeout(function () { + try { + runJobs(clock); + + let numTimers; + if (i < clock.loopLimit) { + if (!clock.timers) { + resetIsNearInfiniteLimit(); + resolve(clock.now); + return; + } + + numTimers = Object.keys( + clock.timers, + ).length; + if (numTimers === 0) { + resetIsNearInfiniteLimit(); + resolve(clock.now); + return; + } + + clock.next(); + + i++; + + doRun(); + checkIsNearInfiniteLimit(clock, i); + return; + } + + const excessJob = firstTimer(clock); + reject(getInfiniteLoopError(clock, excessJob)); + } catch (e) { + reject(e); + } + }); + } + doRun(); + }); + }; + } + + clock.runToLast = function runToLast() { + const timer = lastTimer(clock); + if (!timer) { + runJobs(clock); + return clock.now; + } + + return clock.tick(timer.callAt - clock.now); + }; + + if (typeof _global.Promise !== "undefined") { + clock.runToLastAsync = function runToLastAsync() { + return new _global.Promise(function (resolve, reject) { + originalSetTimeout(function () { + try { + const timer = lastTimer(clock); + if (!timer) { + runJobs(clock); + resolve(clock.now); + } + + resolve(clock.tickAsync(timer.callAt - clock.now)); + } catch (e) { + reject(e); + } + }); + }); + }; + } + + clock.reset = function reset() { + nanos = 0; + clock.timers = {}; + clock.jobs = []; + clock.now = start; + }; + + clock.setSystemTime = function setSystemTime(systemTime) { + // determine time difference + const newNow = getEpoch(systemTime); + const difference = newNow - clock.now; + let id, timer; + + adjustedSystemTime[0] = adjustedSystemTime[0] + difference; + adjustedSystemTime[1] = adjustedSystemTime[1] + nanos; + // update 'system clock' + clock.now = newNow; + nanos = 0; + + // update timers and intervals to keep them stable + for (id in clock.timers) { + if (clock.timers.hasOwnProperty(id)) { + timer = clock.timers[id]; + timer.createdAt += difference; + timer.callAt += difference; + } + } + }; + + /** + * @param {string|number} tickValue number of milliseconds or a human-readable value like "01:11:15" + * @returns {number} will return the new `now` value + */ + clock.jump = function jump(tickValue) { + const msFloat = + typeof tickValue === "number" + ? tickValue + : parseTime(tickValue); + const ms = Math.floor(msFloat); + + for (const timer of Object.values(clock.timers)) { + if (clock.now + ms > timer.callAt) { + timer.callAt = clock.now + ms; + } + } + clock.tick(ms); + }; + + if (performancePresent) { + clock.performance = Object.create(null); + clock.performance.now = fakePerformanceNow; + } + + return clock; + } + + /* eslint-disable complexity */ + + /** + * @param {Config=} [config] Optional config + * @returns {Clock} + */ + function install(config) { + console.log('INSTALL', config); + if ( + arguments.length > 1 || + config instanceof Date || + Array.isArray(config) || + typeof config === "number" + ) { + throw new TypeError( + `FakeTimers.install called with ${String( + config, + )} install requires an object parameter`, + ); + } + + if (_global.Date.isFake === true) { + // Timers are already faked; this is a problem. + // Make the user reset timers before continuing. + throw new TypeError( + "Can't install fake timers twice on the same global object.", + ); + } + + // eslint-disable-next-line no-param-reassign + config = typeof config !== "undefined" ? config : {}; + config.shouldAdvanceTime = config.shouldAdvanceTime || false; + config.advanceTimeDelta = config.advanceTimeDelta || 20; + config.shouldClearNativeTimers = + config.shouldClearNativeTimers || false; + + if (config.target) { + throw new TypeError( + "config.target is no longer supported. Use `withGlobal(target)` instead.", + ); + } + + let i, l; + const clock = createClock(config.now, config.loopLimit); + clock.shouldClearNativeTimers = config.shouldClearNativeTimers; + + clock.uninstall = function () { + return uninstall(clock, config); + }; + + clock.methods = config.toFake || []; + + if (clock.methods.length === 0) { + // do not fake nextTick by default - GitHub#126 + clock.methods = Object.keys(timers).filter(function (key) { + return key !== "nextTick" && key !== "queueMicrotask"; + }); + } + + if (config.shouldAdvanceTime === true) { + const intervalTick = doIntervalTick.bind( + null, + clock, + config.advanceTimeDelta, + ); + const intervalId = _global.setInterval( + intervalTick, + config.advanceTimeDelta, + ); + clock.attachedInterval = intervalId; + } + + if (clock.methods.includes("performance")) { + const proto = (() => { + if (hasPerformanceConstructorPrototype) { + return _global.performance.constructor.prototype; + } + if (hasPerformancePrototype) { + return _global.Performance.prototype; + } + })(); + if (proto) { + Object.getOwnPropertyNames(proto).forEach(function (name) { + if (name !== "now") { + clock.performance[name] = + name.indexOf("getEntries") === 0 + ? NOOP_ARRAY + : NOOP; + } + }); + } else if ((config.toFake || []).includes("performance")) { + // user explicitly tried to fake performance when not present + throw new ReferenceError( + "non-existent performance object cannot be faked", + ); + } + } + + for (i = 0, l = clock.methods.length; i < l; i++) { + const nameOfMethodToReplace = clock.methods[i]; + hijackMethod(_global, nameOfMethodToReplace, clock); + } + + return clock; + } + + /* eslint-enable complexity */ + + return { + timers: timers, + createClock: createClock, + install: install, + withGlobal: withGlobal, + }; +} + +/** + * @typedef {object} FakeTimers + * @property {Function} install + * @property {withGlobal} withGlobal + */ + +/* eslint-enable complexity */ + +/** @type {FakeTimers} */ +const defaultImplementation = withGlobal(globalThis); +exports.install = defaultImplementation.install; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 54d79f9ca1..38cdd75a58 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -4863,6 +4863,11 @@ export interface Page { */ accessibility: Accessibility; + /** + * Playwright is using [@sinonjs/fake-timers](https://github.com/sinonjs/fake-timers) to fake timers and clock. + */ + clock: Clock; + /** * **NOTE** Only available for Chromium atm. * @@ -8980,6 +8985,11 @@ export interface BrowserContext { waitForEvent(event: 'weberror', optionsOrPredicate?: { predicate?: (webError: WebError) => boolean | Promise, timeout?: number } | ((webError: WebError) => boolean | Promise)): Promise; + /** + * Playwright is using [@sinonjs/fake-timers](https://github.com/sinonjs/fake-timers) to fake timers and clock. + */ + clock: Clock; + /** * API testing helper associated with this context. Requests made with this API will use context cookies. */ @@ -17224,6 +17234,81 @@ export interface BrowserServer { [Symbol.asyncDispose](): Promise; } +/** + * Playwright uses [@sinonjs/fake-timers](https://github.com/sinonjs/fake-timers) for clock emulation. Clock is + * installed for the entire {@link BrowserContext}, so the time in all the pages and iframes is controlled by the same + * clock. + */ +export interface Clock { + /** + * Creates a clock and installs it globally. + * @param options + */ + install(options?: { + /** + * Relevant only when using with `shouldAdvanceTime`. Increment mocked time by advanceTimeDelta ms every + * advanceTimeDelta ms change in the real system time (default: 20). + */ + advanceTimeDelta?: number; + + /** + * The maximum number of timers that will be run when calling + * [clock.runAll()](https://playwright.dev/docs/api/class-clock#clock-run-all). Defaults to `1000`. + */ + loopLimit?: number; + + /** + * Install fake timers with the specified unix epoch (default: 0). + */ + now?: number|Date; + + /** + * Tells `@sinonjs/fake-timers` to increment mocked time automatically based on the real system time shift (e.g., the + * mocked time will be incremented by 20ms for every 20ms change in the real system time). Defaults to `false`. + */ + shouldAdvanceTime?: boolean; + + /** + * An array with names of global methods and APIs to fake. For instance, `await page.clock.install({ toFake: + * ['setTimeout'] })` will fake only `setTimeout()`. By default, `setTimeout`, `clearTimeout`, `setInterval`, + * `clearInterval` and `Date` are faked. + */ + toFake?: Array<"setTimeout"|"clearTimeout"|"setInterval"|"clearInterval"|"Date"|"requestAnimationFrame"|"cancelAnimationFrame"|"requestIdleCallback"|"cancelIdleCallback"|"performance">; + }): Promise; + + /** + * Advance the clock by jumping forward in time, firing callbacks at most once. Returns fake milliseconds since the + * unix epoch. This can be used to simulate the JS engine (such as a browser) being put to sleep and resumed later, + * skipping intermediary timers. + * @param time Time may be the number of milliseconds to advance the clock by or a human-readable string. Valid string formats are + * "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten seconds. + */ + jump(time: number|string): Promise; + + /** + * Runs all pending timers until there are none remaining. If new timers are added while it is executing they will be + * run as well. This makes it easier to run asynchronous tests to completion without worrying about the number of + * timers they use, or the delays in those timers. It runs a maximum of `loopLimit` times after which it assumes there + * is an infinite loop of timers and throws an error. + */ + runAll(): Promise; + + /** + * This takes note of the last scheduled timer when it is run, and advances the clock to that time firing callbacks as + * necessary. If new timers are added while it is executing they will be run only if they would occur before this + * time. This is useful when you want to run a test to completion, but the test recursively sets timers that would + * cause runAll to trigger an infinite loop warning. + */ + runToLast(): Promise; + + /** + * Advance the clock, firing callbacks if necessary. Returns fake milliseconds since the unix epoch. + * @param time Time may be the number of milliseconds to advance the clock by or a human-readable string. Valid string formats are + * "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten seconds. + */ + tick(time: number|string): Promise; +} + /** * {@link ConsoleMessage} objects are dispatched by page via the * [page.on('console')](https://playwright.dev/docs/api/class-page#page-event-console) event. For each console message diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 986fb3a8dd..8ad202ca16 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1460,6 +1460,11 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT harExport(params: BrowserContextHarExportParams, metadata?: CallMetadata): Promise; createTempFile(params: BrowserContextCreateTempFileParams, metadata?: CallMetadata): Promise; updateSubscription(params: BrowserContextUpdateSubscriptionParams, metadata?: CallMetadata): Promise; + clockInstall(params: BrowserContextClockInstallParams, metadata?: CallMetadata): Promise; + clockJump(params: BrowserContextClockJumpParams, metadata?: CallMetadata): Promise; + clockRunAll(params?: BrowserContextClockRunAllParams, metadata?: CallMetadata): Promise; + clockRunToLast(params?: BrowserContextClockRunToLastParams, metadata?: CallMetadata): Promise; + clockTick(params: BrowserContextClockTickParams, metadata?: CallMetadata): Promise; } export type BrowserContextBindingCallEvent = { binding: BindingCallChannel, @@ -1748,6 +1753,51 @@ export type BrowserContextUpdateSubscriptionOptions = { }; export type BrowserContextUpdateSubscriptionResult = void; +export type BrowserContextClockInstallParams = { + now?: number, + toFake?: string[], + loopLimit?: number, + shouldAdvanceTime?: boolean, + advanceTimeDelta?: number, +}; +export type BrowserContextClockInstallOptions = { + now?: number, + toFake?: string[], + loopLimit?: number, + shouldAdvanceTime?: boolean, + advanceTimeDelta?: number, +}; +export type BrowserContextClockInstallResult = void; +export type BrowserContextClockJumpParams = { + timeNumber?: number, + timeString?: string, +}; +export type BrowserContextClockJumpOptions = { + timeNumber?: number, + timeString?: string, +}; +export type BrowserContextClockJumpResult = void; +export type BrowserContextClockRunAllParams = {}; +export type BrowserContextClockRunAllOptions = {}; +export type BrowserContextClockRunAllResult = { + fakeTime: number, +}; +export type BrowserContextClockRunToLastParams = {}; +export type BrowserContextClockRunToLastOptions = {}; +export type BrowserContextClockRunToLastResult = { + fakeTime: number, +}; +export type BrowserContextClockTickParams = { + timeNumber?: number, + timeString?: string, +}; +export type BrowserContextClockTickOptions = { + timeNumber?: number, + timeString?: string, +}; +export type BrowserContextClockTickResult = { + fakeTime: number, +}; export interface BrowserContextEvents { 'bindingCall': BrowserContextBindingCallEvent; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 14c6e73052..34020e1212 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1196,6 +1196,36 @@ BrowserContext: - requestFailed enabled: boolean + clockInstall: + parameters: + now: number? + toFake: + type: array? + items: string + loopLimit: number? + shouldAdvanceTime: boolean? + advanceTimeDelta: number? + + clockJump: + parameters: + timeNumber: number? + timeString: string? + + clockRunAll: + returns: + fakeTime: number + + clockRunToLast: + returns: + fakeTime: number + + clockTick: + parameters: + timeNumber: number? + timeString: string? + returns: + fakeTime: number + events: bindingCall: diff --git a/tests/page/page-clock.spec.ts b/tests/page/page-clock.spec.ts new file mode 100644 index 0000000000..c3e2aaeb54 --- /dev/null +++ b/tests/page/page-clock.spec.ts @@ -0,0 +1,614 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './pageTest'; + +declare global { + interface Window { + stub: (param?: any) => void + } +} + +const it = test.extend<{ calls: { params: any[] }[] }>({ + calls: async ({ page }, use) => { + const calls = []; + await page.exposeFunction('stub', async (...params: any[]) => { + calls.push({ params }); + }); + await use(calls); + } +}); + +it.describe('tick', () => { + it('triggers immediately without specified delay', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub); + }); + + await page.clock.tick(0); + expect(calls).toEqual([{ params: [] }]); + }); + + it('does not trigger without sufficient delay', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 100); + }); + await page.clock.tick(10); + expect(calls).toEqual([]); + }); + + it('triggers after sufficient delay', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 100); + }); + await page.clock.tick(100); + expect(calls).toEqual([{ params: [] }]); + }); + + it('triggers simultaneous timers', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 100); + setTimeout(window.stub, 100); + }); + await page.clock.tick(100); + expect(calls).toEqual([{ params: [] }, { params: [] }]); + }); + + it('triggers multiple simultaneous timers', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 100); + setTimeout(window.stub, 100); + setTimeout(window.stub, 99); + setTimeout(window.stub, 100); + }); + await page.clock.tick(100); + expect(calls.length).toBe(4); + }); + + it('waits after setTimeout was called', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 150); + }); + await page.clock.tick(50); + expect(calls).toEqual([]); + await page.clock.tick(100); + expect(calls).toEqual([{ params: [] }]); + }); + + it('triggers event when some throw', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { throw new Error(); }, 100); + setTimeout(window.stub, 120); + }); + + await expect(page.clock.tick(120)).rejects.toThrow(); + expect(calls).toEqual([{ params: [] }]); + }); + + it('creates updated Date while ticking', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setInterval(() => { + window.stub(new Date().getTime()); + }, 10); + }); + await page.clock.tick(100); + expect(calls).toEqual([ + { params: [10] }, + { params: [20] }, + { params: [30] }, + { params: [40] }, + { params: [50] }, + { params: [60] }, + { params: [70] }, + { params: [80] }, + { params: [90] }, + { params: [100] }, + ]); + }); + + it('passes 8 seconds', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setInterval(window.stub, 4000); + }); + + await page.clock.tick('08'); + expect(calls.length).toBe(2); + }); + + it('passes 1 minute', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setInterval(window.stub, 6000); + }); + + await page.clock.tick('01:00'); + expect(calls.length).toBe(10); + }); + + it('passes 2 hours, 34 minutes and 10 seconds', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setInterval(window.stub, 10000); + }); + + await page.clock.tick('02:34:10'); + expect(calls.length).toBe(925); + }); + + it('throws for invalid format', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setInterval(window.stub, 10000); + }); + await expect(page.clock.tick('12:02:34:10')).rejects.toThrow(); + expect(calls).toEqual([]); + }); + + it('returns the current now value', async ({ page }) => { + await page.clock.install(); + const value = 200; + await page.clock.tick(value); + expect(await page.evaluate(() => Date.now())).toBe(value); + }); +}); + +it.describe('jump', () => { + it(`ignores timers which wouldn't be run`, async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + window.stub('should not be logged'); + }, 1000); + }); + await page.clock.jump(500); + expect(calls).toEqual([]); + }); + + it('pushes back execution time for skipped timers', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + window.stub(Date.now()); + }, 1000); + }); + + await page.clock.jump(2000); + expect(calls).toEqual([{ params: [2000] }]); + }); + + it('supports string time arguments', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + window.stub(Date.now()); + }, 100000); // 100000 = 1:40 + }); + await page.clock.jump('01:50'); + expect(calls).toEqual([{ params: [110000] }]); + }); +}); + +it.describe('runAllAsyn', () => { + it('if there are no timers just return', async ({ page }) => { + await page.clock.install(); + await page.clock.runAll(); + }); + + it('runs all timers', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 10); + setTimeout(window.stub, 50); + }); + await page.clock.runAll(); + expect(calls.length).toBe(2); + }); + + it('new timers added while running are also run', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + setTimeout(window.stub, 50); + }, 10); + }); + await page.clock.runAll(); + expect(calls.length).toBe(1); + }); + + it('new timers added in promises while running are also run', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + void Promise.resolve().then(() => { + setTimeout(window.stub, 50); + }); + }, 10); + }); + await page.clock.runAll(); + expect(calls.length).toBe(1); + }); + + it('throws before allowing infinite recursion', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + const recursiveCallback = () => { + window.stub(); + setTimeout(recursiveCallback, 10); + }; + setTimeout(recursiveCallback, 10); + }); + await expect(page.clock.runAll()).rejects.toThrow(); + expect(calls).toHaveLength(1000); + }); + + it('throws before allowing infinite recursion from promises', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + const recursiveCallback = () => { + window.stub(); + void Promise.resolve().then(() => { + setTimeout(recursiveCallback, 10); + }); + }; + setTimeout(recursiveCallback, 10); + }); + await expect(page.clock.runAll()).rejects.toThrow(); + expect(calls).toHaveLength(1000); + }); + + it('the loop limit can be set when creating a clock', async ({ page, calls }) => { + await page.clock.install({ loopLimit: 1 }); + await page.evaluate(async () => { + setTimeout(window.stub, 10); + setTimeout(window.stub, 50); + }); + await expect(page.clock.runAll()).rejects.toThrow(); + expect(calls).toHaveLength(1); + }); + + it('should settle user-created promises', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + void Promise.resolve().then(() => window.stub()); + }, 55); + }); + await page.clock.runAll(); + expect(calls).toHaveLength(1); + }); + + it('should settle nested user-created promises', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + void Promise.resolve().then(() => { + void Promise.resolve().then(() => { + void Promise.resolve().then(() => window.stub()); + }); + }); + }, 55); + }); + await page.clock.runAll(); + expect(calls).toHaveLength(1); + }); + + it('should settle local promises before firing timers', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + void Promise.resolve().then(() => window.stub(1)); + setTimeout(() => window.stub(2), 55); + }); + await page.clock.runAll(); + expect(calls).toEqual([ + { params: [1] }, + { params: [2] }, + ]); + }); +}); + +it.describe('runToLast', () => { + it('returns current time when there are no timers', async ({ page }) => { + await page.clock.install(); + const time = await page.clock.runToLast(); + expect(time).toBe(0); + }); + + it('runs all existing timers', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 10); + setTimeout(window.stub, 50); + }); + await page.clock.runToLast(); + expect(calls.length).toBe(2); + }); + + it('returns time of the last timer', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 10); + setTimeout(window.stub, 50); + }); + const time = await page.clock.runToLast(); + expect(time).toBe(50); + }); + + it('runs all existing timers when two timers are matched for being last', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 10); + setTimeout(window.stub, 10); + }); + await page.clock.runToLast(); + expect(calls.length).toBe(2); + }); + + it('new timers added with a call time later than the last existing timer are NOT run', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + window.stub(); + setTimeout(window.stub, 50); + }, 10); + }); + await page.clock.runToLast(); + expect(calls.length).toBe(1); + }); + + it('new timers added with a call time earlier than the last existing timer are run', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 100); + setTimeout(() => { + setTimeout(window.stub, 50); + }, 10); + }); + await page.clock.runToLast(); + expect(calls.length).toBe(2); + }); + + it('new timers cannot cause an infinite loop', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + const recursiveCallback = () => { + window.stub(); + setTimeout(recursiveCallback, 0); + }; + setTimeout(recursiveCallback, 0); + setTimeout(window.stub, 100); + }); + await page.clock.runToLast(); + expect(calls.length).toBe(102); + }); + + it('should support clocks with start time', async ({ page, calls }) => { + await page.clock.install({ now: 200 }); + await page.evaluate(async () => { + setTimeout(function cb() { + window.stub(); + setTimeout(cb, 50); + }, 50); + }); + await page.clock.runToLast(); + expect(calls.length).toBe(1); + }); + + it('new timers created from promises cannot cause an infinite loop', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + const recursiveCallback = () => { + void Promise.resolve().then(() => { + setTimeout(recursiveCallback, 0); + }); + }; + setTimeout(recursiveCallback, 0); + setTimeout(window.stub, 100); + }); + await page.clock.runToLast(); + expect(calls.length).toBe(1); + }); + + it('should settle user-created promises', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + void Promise.resolve().then(() => window.stub()); + }, 55); + }); + await page.clock.runToLast(); + expect(calls.length).toBe(1); + }); + + it('should settle nested user-created promises', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + void Promise.resolve().then(() => { + void Promise.resolve().then(() => { + void Promise.resolve().then(() => window.stub()); + }); + }); + }, 55); + }); + await page.clock.runToLast(); + expect(calls.length).toBe(1); + }); + + it('should settle local promises before firing timers', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + void Promise.resolve().then(() => window.stub(1)); + setTimeout(() => window.stub(2), 55); + }); + await page.clock.runToLast(); + expect(calls).toEqual([ + { params: [1] }, + { params: [2] }, + ]); + }); + + it('should settle user-created promises before firing more timers', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(() => { + void Promise.resolve().then(() => window.stub(1)); + }, 55); + setTimeout(() => window.stub(2), 75); + }); + await page.clock.runToLast(); + expect(calls).toEqual([ + { params: [1] }, + { params: [2] }, + ]); + }); +}); + +it.describe('stubTimers', () => { + it('sets initial timestamp', async ({ page, calls }) => { + await page.clock.install({ now: 1400 }); + expect(await page.evaluate(() => Date.now())).toBe(1400); + }); + + it('replaces global setTimeout', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setTimeout(window.stub, 1000); + }); + await page.clock.tick(1000); + expect(calls.length).toBe(1); + }); + + it('global fake setTimeout should return id', async ({ page, calls }) => { + await page.clock.install(); + const to = await page.evaluate(() => setTimeout(window.stub, 1000)); + expect(typeof to).toBe('number'); + }); + + it('replaces global clearTimeout', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + const to = setTimeout(window.stub, 1000); + clearTimeout(to); + }); + await page.clock.tick(1000); + expect(calls).toEqual([]); + }); + + it('replaces global setInterval', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + setInterval(window.stub, 500); + }); + await page.clock.tick(1000); + expect(calls.length).toBe(2); + }); + + it('replaces global clearInterval', async ({ page, calls }) => { + await page.clock.install(); + await page.evaluate(async () => { + const to = setInterval(window.stub, 500); + clearInterval(to); + }); + await page.clock.tick(1000); + expect(calls).toEqual([]); + }); + + it('replaces global performance.now', async ({ page }) => { + await page.clock.install(); + const promise = page.evaluate(async () => { + const prev = performance.now(); + await new Promise(f => setTimeout(f, 1000)); + const next = performance.now(); + return { prev, next }; + }); + await page.clock.tick(1000); + expect(await promise).toEqual({ prev: 0, next: 1000 }); + }); + + it('fakes Date constructor', async ({ page }) => { + await page.clock.install({ now: 0 }); + const now = await page.evaluate(() => new Date().getTime()); + expect(now).toBe(0); + }); + + it('does not fake methods not provided', async ({ page }) => { + await page.clock.install({ + now: 0, + toFake: ['Date'], + }); + + // Should not stall. + await page.evaluate(() => { + return new Promise(f => setTimeout(f, 1)); + }); + }); +}); + +it.describe('shouldAdvanceTime', () => { + it('should create an auto advancing timer', async ({ page, calls }) => { + const testDelay = 29; + const now = new Date('2015-09-25'); + await page.clock.install({ now, shouldAdvanceTime: true }); + const pageNow = await page.evaluate(() => Date.now()); + expect(pageNow).toBe(1443139200000); + + await page.evaluate(async testDelay => { + return new Promise(f => { + const timeoutStarted = Date.now(); + setTimeout(() => { + window.stub(Date.now() - timeoutStarted); + f(); + }, testDelay); + }); + }, testDelay); + + expect(calls).toEqual([ + { params: [testDelay] } + ]); + }); + + it('should test setInterval', async ({ page, calls }) => { + const now = new Date('2015-09-25'); + await page.clock.install({ now, shouldAdvanceTime: true }); + + const timeDifference = await page.evaluate(async () => { + return new Promise(f => { + const interval = 20; + const cyclesToTrigger = 3; + const timeoutStarted = Date.now(); + let intervalsTriggered = 0; + const intervalId = setInterval(() => { + if (++intervalsTriggered === cyclesToTrigger) { + clearInterval(intervalId); + const timeDifference = Date.now() - timeoutStarted; + f(timeDifference - interval * cyclesToTrigger); + } + }, interval); + }); + }); + + expect(timeDifference).toBe(0); + }); +}); diff --git a/utils/doclint/documentation.js b/utils/doclint/documentation.js index b93998bd33..7972ba507e 100644 --- a/utils/doclint/documentation.js +++ b/utils/doclint/documentation.js @@ -865,6 +865,8 @@ function csharpOptionOverloadSuffix(option, type) { case 'function': return 'Func'; case 'Buffer': return 'Byte'; case 'Serializable': return 'Object'; + case 'int': return 'Int'; + case 'Date': return 'Date'; } throw new Error(`CSharp option "${option}" has unsupported type overload "${type}"`); } diff --git a/utils/generate_injected.js b/utils/generate_injected.js index eccd7ddcad..a39583ab38 100644 --- a/utils/generate_injected.js +++ b/utils/generate_injected.js @@ -50,6 +50,12 @@ const injectedScripts = [ path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), true, ], + [ + path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'fakeTimers.ts'), + path.join(ROOT, 'packages', 'playwright-core', 'lib', 'server', 'injected', 'packed'), + path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), + true, + ], [ path.join(ROOT, 'packages', 'playwright-ct-core', 'src', 'injected', 'index.ts'), path.join(ROOT, 'packages', 'playwright-ct-core', 'lib', 'injected', 'packed'), From d6d373c45927cf4a913ac686c67bee942c97e3c5 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 30 May 2024 18:56:03 +0200 Subject: [PATCH 10/15] devops: fix client side changes GHA workflow Signed-off-by: Max Schmitt --- .github/workflows/pr_check_client_side_changes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_check_client_side_changes.yml b/.github/workflows/pr_check_client_side_changes.yml index 236da1cc88..7748b5d514 100644 --- a/.github/workflows/pr_check_client_side_changes.yml +++ b/.github/workflows/pr_check_client_side_changes.yml @@ -32,7 +32,7 @@ jobs: const title = '[Ports]: Backport client side changes for ' + currentPlaywrightVersion; for (const repo of ['playwright-python', 'playwright-java', 'playwright-dotnet']) { const { data: issuesData } = await github.rest.search.issuesAndPullRequests({ - q: `is:issue is:open repo:microsoft/${repo} in:title "${title}" author:playwrightmachine"` + q: `is:issue is:open repo:microsoft/${repo} in:title "${title}" author:playwrightmachine` }) let issueNumber = null; let issueBody = ''; From 6067b78f88680c38f7e73c2f9abc498e8fb5dd3d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 30 May 2024 10:19:56 -0700 Subject: [PATCH 11/15] chore: http credentials send immeidately/unauthorized enum (#31076) Reference https://github.com/microsoft/playwright-internal/issues/205 Reference https://github.com/microsoft/playwright/issues/30534 --- docs/src/api/params.md | 2 +- .../playwright-core/src/protocol/validator.ts | 10 ++-- packages/playwright-core/src/server/fetch.ts | 2 +- packages/playwright-core/types/types.d.ts | 54 ++++++++++--------- packages/protocol/src/channels.ts | 20 +++---- packages/protocol/src/protocol.yml | 12 ++++- tests/library/browsercontext-fetch.spec.ts | 29 +++++++++- tests/library/global-fetch.spec.ts | 2 +- 8 files changed, 85 insertions(+), 46 deletions(-) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index ffff0a800e..a1dd84c4ef 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -571,7 +571,7 @@ Whether to emulate network being offline. Defaults to `false`. Learn more about - `username` <[string]> - `password` <[string]> - `origin` ?<[string]> Restrain sending http credentials on specific origin (scheme://host:port). - - `sendImmediately` ?<[boolean]> Whether to send `Authorization` header with the first API request. By default, the credentials are sent only when 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent from the browser. + - `send` ?<[HttpCredentialsSend]<"unauthorized"|"always">> This option only applies to the requests sent from corresponding [APIRequestContext] and does not affect requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 4d505069e1..30c7856ca5 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -334,7 +334,7 @@ scheme.PlaywrightNewRequestParams = tObject({ username: tString, password: tString, origin: tOptional(tString), - sendImmediately: tOptional(tBoolean), + send: tOptional(tEnum(['always', 'unauthorized'])), })), proxy: tOptional(tObject({ server: tString, @@ -548,7 +548,7 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({ username: tString, password: tString, origin: tOptional(tString), - sendImmediately: tOptional(tBoolean), + send: tOptional(tEnum(['always', 'unauthorized'])), })), deviceScaleFactor: tOptional(tNumber), isMobile: tOptional(tBoolean), @@ -627,7 +627,7 @@ scheme.BrowserNewContextParams = tObject({ username: tString, password: tString, origin: tOptional(tString), - sendImmediately: tOptional(tBoolean), + send: tOptional(tEnum(['always', 'unauthorized'])), })), deviceScaleFactor: tOptional(tNumber), isMobile: tOptional(tBoolean), @@ -689,7 +689,7 @@ scheme.BrowserNewContextForReuseParams = tObject({ username: tString, password: tString, origin: tOptional(tString), - sendImmediately: tOptional(tBoolean), + send: tOptional(tEnum(['always', 'unauthorized'])), })), deviceScaleFactor: tOptional(tNumber), isMobile: tOptional(tBoolean), @@ -2506,7 +2506,7 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({ username: tString, password: tString, origin: tOptional(tString), - sendImmediately: tOptional(tBoolean), + send: tOptional(tEnum(['always', 'unauthorized'])), })), deviceScaleFactor: tOptional(tNumber), isMobile: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index e15c5aca8a..0f87e0046b 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -159,7 +159,7 @@ export abstract class APIRequestContext extends SdkObject { } const credentials = this._getHttpCredentials(requestUrl); - if (credentials?.sendImmediately) + if (credentials?.send === 'always') setBasicAuthorizationHeader(headers, credentials); const method = params.method?.toUpperCase() || 'GET'; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 38cdd75a58..0f88ed92c5 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13393,11 +13393,12 @@ export interface BrowserType { origin?: string; /** - * Whether to send `Authorization` header with the first API request. By default, the credentials are sent only when - * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent - * from the browser. + * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect + * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be + * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response + * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. */ - sendImmediately?: boolean; + send?: "unauthorized"|"always"; }; /** @@ -14930,11 +14931,12 @@ export interface AndroidDevice { origin?: string; /** - * Whether to send `Authorization` header with the first API request. By default, the credentials are sent only when - * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent - * from the browser. + * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect + * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be + * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response + * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. */ - sendImmediately?: boolean; + send?: "unauthorized"|"always"; }; /** @@ -15661,11 +15663,12 @@ export interface APIRequest { origin?: string; /** - * Whether to send `Authorization` header with the first API request. By default, the credentials are sent only when - * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent - * from the browser. + * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect + * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be + * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response + * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. */ - sendImmediately?: boolean; + send?: "unauthorized"|"always"; }; /** @@ -16818,11 +16821,12 @@ export interface Browser extends EventEmitter { origin?: string; /** - * Whether to send `Authorization` header with the first API request. By default, the credentials are sent only when - * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent - * from the browser. + * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect + * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be + * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response + * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. */ - sendImmediately?: boolean; + send?: "unauthorized"|"always"; }; /** @@ -17790,11 +17794,12 @@ export interface Electron { origin?: string; /** - * Whether to send `Authorization` header with the first API request. By default, the credentials are sent only when - * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent - * from the browser. + * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect + * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be + * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response + * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. */ - sendImmediately?: boolean; + send?: "unauthorized"|"always"; }; /** @@ -20458,11 +20463,12 @@ export interface HTTPCredentials { origin?: string; /** - * Whether to send `Authorization` header with the first API request. By default, the credentials are sent only when - * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent - * from the browser. + * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect + * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be + * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response + * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. */ - sendImmediately?: boolean; + send?: "unauthorized"|"always"; } export interface Geolocation { diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 8ad202ca16..df8658ee0d 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -578,7 +578,7 @@ export type PlaywrightNewRequestParams = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, proxy?: { server: string, @@ -602,7 +602,7 @@ export type PlaywrightNewRequestOptions = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, proxy?: { server: string, @@ -959,7 +959,7 @@ export type BrowserTypeLaunchPersistentContextParams = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1032,7 +1032,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1140,7 +1140,7 @@ export type BrowserNewContextParams = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1199,7 +1199,7 @@ export type BrowserNewContextOptions = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1261,7 +1261,7 @@ export type BrowserNewContextForReuseParams = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1320,7 +1320,7 @@ export type BrowserNewContextForReuseOptions = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, deviceScaleFactor?: number, isMobile?: boolean, @@ -4529,7 +4529,7 @@ export type AndroidDeviceLaunchBrowserParams = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, deviceScaleFactor?: number, isMobile?: boolean, @@ -4586,7 +4586,7 @@ export type AndroidDeviceLaunchBrowserOptions = { username: string, password: string, origin?: string, - sendImmediately?: boolean, + send?: 'always' | 'unauthorized', }, deviceScaleFactor?: number, isMobile?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 34020e1212..96a511d306 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -456,7 +456,11 @@ ContextOptions: username: string password: string origin: string? - sendImmediately: boolean? + send: + type: enum? + literals: + - always + - unauthorized deviceScaleFactor: number? isMobile: boolean? hasTouch: boolean? @@ -674,7 +678,11 @@ Playwright: username: string password: string origin: string? - sendImmediately: boolean? + send: + type: enum? + literals: + - always + - unauthorized proxy: type: object? properties: diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index 99fd0265a3..ba01ccc07c 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -435,10 +435,10 @@ it('should return error with wrong credentials', async ({ context, server }) => expect(response2.status()).toBe(401); }); -it('should support HTTPCredentials.sendImmediately', async ({ contextFactory, server }) => { +it('should support HTTPCredentials.sendImmediately for newContext', async ({ contextFactory, server }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30534' }); const context = await contextFactory({ - httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase(), sendImmediately: true } + httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase(), send: 'always' } }); { const [serverRequest, response] = await Promise.all([ @@ -459,6 +459,31 @@ it('should support HTTPCredentials.sendImmediately', async ({ contextFactory, se } }); +it('should support HTTPCredentials.sendImmediately for browser.newPage', async ({ contextFactory, server, browser }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30534' }); + const page = await browser.newPage({ + httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase(), send: 'always' } + }); + { + const [serverRequest, response] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.request.get(server.EMPTY_PAGE) + ]); + expect(serverRequest.headers.authorization).toBe('Basic ' + Buffer.from('user:pass').toString('base64')); + expect(response.status()).toBe(200); + } + { + const [serverRequest, response] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.request.get(server.CROSS_PROCESS_PREFIX + '/empty.html') + ]); + // Not sent to another origin. + expect(serverRequest.headers.authorization).toBe(undefined); + expect(response.status()).toBe(200); + } + await page.close(); +}); + it('delete should support post data', async ({ context, server }) => { const [request, response] = await Promise.all([ server.waitForRequest('/simple.json'), diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index 58dec04519..a2eb629dc5 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -157,7 +157,7 @@ it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => { it('should support HTTPCredentials.sendImmediately', async ({ playwright, server }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30534' }); const request = await playwright.request.newContext({ - httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase(), sendImmediately: true } + httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase(), send: 'always' } }); { const [serverRequest, response] = await Promise.all([ From 570814849657da8fe86a90269e239f41e718750d Mon Sep 17 00:00:00 2001 From: Mathias Leppich Date: Thu, 30 May 2024 20:29:20 +0200 Subject: [PATCH 12/15] fix(merge-reports): only change test ids when needed (#31061) When merging blob reports test ids are patched to make sure there is no collision when merging reports that might have overlapping test ids. However, even if you were merging reports that had no overlapping ids, all test ids will be modified, which is an undesirable side effect. This PR only modify test ids when the same test id has already been used in a previous blob report. ---- This change is also part of https://github.com/microsoft/playwright/pull/30962 --- packages/playwright/src/reporters/merge.ts | 36 ++++++++- .../playwright-test-fixtures.ts | 14 +++- tests/playwright-test/reporter-blob.spec.ts | 73 ++++++++++++++++++- 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/packages/playwright/src/reporters/merge.ts b/packages/playwright/src/reporters/merge.ts index 17272a90d8..5eedcac136 100644 --- a/packages/playwright/src/reporters/merge.ts +++ b/packages/playwright/src/reporters/merge.ts @@ -194,12 +194,18 @@ async function mergeEvents(dir: string, shardReportFiles: string[], stringPool: printStatus(`merging events`); const reports: ReportData[] = []; + const globalTestIdSet = new Set(); for (let i = 0; i < blobs.length; ++i) { // Generate unique salt for each blob. const { parsedEvents, metadata, localPath } = blobs[i]; const eventPatchers = new JsonEventPatchers(); - eventPatchers.patchers.push(new IdsPatcher(stringPool, metadata.name, String(i))); + eventPatchers.patchers.push(new IdsPatcher( + stringPool, + metadata.name, + String(i), + globalTestIdSet, + )); // Only patch path separators if we are merging reports with explicit config. if (rootDirOverride) eventPatchers.patchers.push(new PathSeparatorPatcher(metadata.pathSeparator)); @@ -358,11 +364,20 @@ class IdsPatcher { private _stringPool: StringInternPool; private _botName: string | undefined; private _salt: string; + private _testIdsMap: Map; + private _globalTestIdSet: Set; - constructor(stringPool: StringInternPool, botName: string | undefined, salt: string) { + constructor( + stringPool: StringInternPool, + botName: string | undefined, + salt: string, + globalTestIdSet: Set, + ) { this._stringPool = stringPool; this._botName = botName; this._salt = salt; + this._testIdsMap = new Map(); + this._globalTestIdSet = globalTestIdSet; } patchEvent(event: JsonEvent) { @@ -406,7 +421,20 @@ class IdsPatcher { } private _mapTestId(testId: string): string { - return this._stringPool.internString(testId + this._salt); + const t1 = this._stringPool.internString(testId); + if (this._testIdsMap.has(t1)) + // already mapped + return this._testIdsMap.get(t1)!; + if (this._globalTestIdSet.has(t1)) { + // test id is used in another blob, so we need to salt it. + const t2 = this._stringPool.internString(testId + this._salt); + this._globalTestIdSet.add(t2); + this._testIdsMap.set(t1, t2); + return t2; + } + this._globalTestIdSet.add(t1); + this._testIdsMap.set(t1, t1); + return t1; } } @@ -551,4 +579,4 @@ class BlobModernizer { } } -const modernizer = new BlobModernizer(); \ No newline at end of file +const modernizer = new BlobModernizer(); diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index 62e20d6945..4f0350cb59 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -31,6 +31,7 @@ export { countTimes } from '../config/commonFixtures'; type CliRunResult = { exitCode: number, output: string, + outputLines: string[], }; export type RunResult = { @@ -330,7 +331,12 @@ export const test = base cwd, }); const { exitCode } = await testProcess.exited; - return { exitCode, output: testProcess.output.toString() }; + const output = testProcess.output.toString(); + return { + exitCode, + output, + outputLines: parseOutputLines(output), + }; }); }, @@ -416,6 +422,10 @@ export function expectTestHelper(result: RunResult) { }; } +function parseOutputLines(output: string): string[] { + return output.split('\n').filter(line => line.startsWith('%%')).map(line => line.substring(2).trim()); +} + export function parseTestRunnerOutput(output: string) { const summary = (re: RegExp) => { let result = 0; @@ -436,7 +446,7 @@ export function parseTestRunnerOutput(output: string) { const strippedOutput = stripAnsi(output); return { output: strippedOutput, - outputLines: strippedOutput.split('\n').filter(line => line.startsWith('%%')).map(line => line.substring(2).trim()), + outputLines: parseOutputLines(strippedOutput), rawOutput: output, passed, failed, diff --git a/tests/playwright-test/reporter-blob.spec.ts b/tests/playwright-test/reporter-blob.spec.ts index ca3020a43f..99882777a8 100644 --- a/tests/playwright-test/reporter-blob.spec.ts +++ b/tests/playwright-test/reporter-blob.spec.ts @@ -36,7 +36,7 @@ const test = baseTest.extend<{ showReport: async ({ page }, use) => { let server: HttpServer | undefined; await use(async (reportFolder?: string) => { - reportFolder ??= test.info().outputPath('playwright-report'); + reportFolder ??= test.info().outputPath('playwright-report'); server = startHtmlReportServer(reportFolder) as HttpServer; await server.start(); await page.goto(server.urlPrefix('precise')); @@ -1813,6 +1813,77 @@ test('merge reports without --config preserves path separators', async ({ runInl expect(output).toContain(`test title: ${'tests2' + otherSeparator + 'b.test.js'}`); }); +test('merge reports must not change test ids when there is no need to', async ({ runInlineTest, mergeReports }) => { + const files = { + 'echo-test-id-reporter.js': ` + export default class EchoTestIdReporter { + onTestBegin(test) { + console.log('%%' + test.id); + } + }; + `, + 'single-run.config.ts': `module.exports = { + reporter: [ + ['./echo-test-id-reporter.js'], + ] + };`, + 'shard-1.config.ts': `module.exports = { + fullyParallel: true, + shard: { total: 2, current: 1 }, + reporter: [ + ['./echo-test-id-reporter.js'], + ['blob', { outputDir: 'blob-report' }], + ] + };`, + 'shard-2.config.ts': `module.exports = { + fullyParallel: true, + shard: { total: 2, current: 2 }, + reporter: [ + ['./echo-test-id-reporter.js'], + ['blob', { outputDir: 'blob-report' }], + ] + };`, + 'merge.config.ts': `module.exports = { + reporter: [ + ['./echo-test-id-reporter.js'], + ] + };`, + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('test 1', async ({}) => { }); + test('test 2', async ({}) => { }); + test('test 3', async ({}) => { }); + `, + }; + let testIdsFromSingleRun: string[]; + let testIdsFromShard1: string[]; + let testIdsFromShard2: string[]; + let testIdsFromMergedReport: string[]; + { + const { exitCode, outputLines } = await runInlineTest(files, { workers: 1 }, undefined, { additionalArgs: ['--config', test.info().outputPath('single-run.config.ts')] }); + expect(exitCode).toBe(0); + testIdsFromSingleRun = outputLines.sort(); + expect(testIdsFromSingleRun.length).toEqual(3); + } + { + const { exitCode, outputLines } = await runInlineTest(files, { workers: 1 }, {}, { additionalArgs: ['--config', test.info().outputPath('shard-1.config.ts')] }); + expect(exitCode).toBe(0); + testIdsFromShard1 = outputLines.sort(); + } + { + const { exitCode, outputLines } = await runInlineTest(files, { workers: 1 }, { PWTEST_BLOB_DO_NOT_REMOVE: '1' }, { additionalArgs: ['--config', test.info().outputPath('shard-2.config.ts')] }); + expect(exitCode).toBe(0); + testIdsFromShard2 = outputLines.sort(); + expect([...testIdsFromShard1, ...testIdsFromShard2].sort()).toEqual(testIdsFromSingleRun); + } + { + const { exitCode, outputLines } = await mergeReports(test.info().outputPath('blob-report'), undefined, { additionalArgs: ['--config', test.info().outputPath('merge.config.ts')] }); + expect(exitCode).toBe(0); + testIdsFromMergedReport = outputLines.sort(); + expect(testIdsFromMergedReport).toEqual(testIdsFromSingleRun); + } +}); + test('TestSuite.project() should return owning project', async ({ runInlineTest, mergeReports }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29173' }); const files1 = { From f97d87ea5ac6567c6b9010a9ddbb4c563cc1fb46 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 30 May 2024 12:15:52 -0700 Subject: [PATCH 13/15] docs: fix the api review typos (#31071) --- docs/src/api/class-apirequestcontext.md | 2 +- docs/src/test-api/class-testconfig.md | 2 +- packages/playwright-core/types/types.d.ts | 2 +- packages/playwright/types/test.d.ts | 5 ++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 0d2be68109..7101d1996d 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -189,7 +189,7 @@ All responses returned by [`method: APIRequestContext.get`] and similar methods * since: v1.45 - `reason` <[string]> -The reason to be reported to the operations interrupted by the context disposure. +The reason to be reported to the operations interrupted by the context disposal. ## async method: APIRequestContext.fetch * since: v1.16 diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 85fbd3e86f..93a3c25cab 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -443,7 +443,7 @@ Test files that took more than `threshold` milliseconds are considered slow, and * since: v1.45 - type: ?<[boolean]> -Whether to skip entries from `.gitignore` when searching for test files. By default, if neither [`property: TestConfig.testDir`] nor [`property: TestProject.testDir`] are explicitely specified, Playwright will ignore any test files matching `.gitignore` entries. This option allows to override that behavior. +Whether to skip entries from `.gitignore` when searching for test files. By default, if neither [`property: TestConfig.testDir`] nor [`property: TestProject.testDir`] are explicitly specified, Playwright will ignore any test files matching `.gitignore` entries. ## property: TestConfig.retries * since: v1.10 diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 0f88ed92c5..6757e3b79c 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -15877,7 +15877,7 @@ export interface APIRequestContext { */ dispose(options?: { /** - * The reason to be reported to the operations interrupted by the context disposure. + * The reason to be reported to the operations interrupted by the context disposal. */ reason?: string; }): Promise; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 59ae5256b7..3d68c341e8 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -1374,9 +1374,8 @@ interface TestConfig { /** * Whether to skip entries from `.gitignore` when searching for test files. By default, if neither * [testConfig.testDir](https://playwright.dev/docs/api/class-testconfig#test-config-test-dir) nor - * [testProject.testDir](https://playwright.dev/docs/api/class-testproject#test-project-test-dir) are explicitely - * specified, Playwright will ignore any test files matching `.gitignore` entries. This option allows to override that - * behavior. + * [testProject.testDir](https://playwright.dev/docs/api/class-testproject#test-project-test-dir) are explicitly + * specified, Playwright will ignore any test files matching `.gitignore` entries. */ respectGitIgnore?: boolean; From 4c020c986100a71ca96a33c558c59491efaae40f Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 30 May 2024 12:21:32 -0700 Subject: [PATCH 14/15] chore(trace-viewer): preserve column widths after showing resource details (#31093) * Column widths are now stored on in the NetworkPanel context, this way they are not reset after selecting an empty range (and changing position of the NetworkGridView in the component tree). * Column widths values are now preserved if column set changes (e.g. selecting entries from a single context and then from multiple contexts). --- packages/trace-viewer/src/ui/networkTab.tsx | 20 ++++++++---- packages/web/src/components/gridView.tsx | 35 +++++++++++++++------ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index d007d58198..ecab8cacf0 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -76,6 +76,10 @@ export const NetworkTab: React.FunctionComponent<{ return { renderedEntries }; }, [networkModel.resources, networkModel.contextIdMap, sorting, boundaries]); + const [widths, setWidths] = React.useState>(() => { + return new Map(allColumns().map(column => [column, columnWidth(column)])); + }); + if (!networkModel.resources.length) return ; @@ -87,7 +91,8 @@ export const NetworkTab: React.FunctionComponent<{ onHighlighted={item => onEntryHovered(item?.resource)} columns={visibleColumns(!!selectedEntry, renderedEntries)} columnTitle={columnTitle} - columnWidth={columnWidth} + columnWidths={widths} + setColumnWidths={setWidths} isError={item => item.status.code >= 400} isInfo={item => !!item.route} render={(item, column) => renderCell(item, column)} @@ -96,7 +101,7 @@ export const NetworkTab: React.FunctionComponent<{ />; return <> {!selectedEntry && grid} - {selectedEntry && + {selectedEntry && setSelectedEntry(undefined)} /> {grid} } @@ -142,13 +147,16 @@ const columnWidth = (column: ColumnName) => { function visibleColumns(entrySelected: boolean, renderedEntries: RenderedEntry[]): (keyof RenderedEntry)[] { if (entrySelected) return ['name']; - const columns: (keyof RenderedEntry)[] = []; - if (hasMultipleContexts(renderedEntries)) - columns.push('contextId'); - columns.push('name', 'method', 'status', 'contentType', 'duration', 'size', 'start', 'route'); + let columns: (keyof RenderedEntry)[] = allColumns(); + if (!hasMultipleContexts(renderedEntries)) + columns = columns.filter(name => name !== 'contextId'); return columns; } +function allColumns(): (keyof RenderedEntry)[] { + return ['contextId', 'name', 'method', 'status', 'contentType', 'duration', 'size', 'start', 'route']; +} + const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell => { if (column === 'contextId') { return { diff --git a/packages/web/src/components/gridView.tsx b/packages/web/src/components/gridView.tsx index 2a5257da2b..d5f0f905fb 100644 --- a/packages/web/src/components/gridView.tsx +++ b/packages/web/src/components/gridView.tsx @@ -30,19 +30,34 @@ export type RenderedGridCell = { export type GridViewProps = Omit, 'render'> & { columns: (keyof T)[], columnTitle: (column: keyof T) => string, - columnWidth: (column: keyof T) => number, + columnWidths: Map, + setColumnWidths: (widths: Map) => void, render: (item: T, column: keyof T, index: number) => RenderedGridCell, sorting?: Sorting, setSorting?: (sorting: Sorting | undefined) => void, }; export function GridView(model: GridViewProps) { - const initialOffsets: number[] = []; - for (let i = 0; i < model.columns.length - 1; ++i) { - const column = model.columns[i]; - initialOffsets[i] = (initialOffsets[i - 1] || 0) + model.columnWidth(column); + const [offsets, setOffsets] = React.useState([]); + + React.useEffect(() => { + const offsets: number[] = []; + for (let i = 0; i < model.columns.length - 1; ++i) { + const column = model.columns[i]; + offsets[i] = (offsets[i - 1] || 0) + model.columnWidths.get(column)!; + } + setOffsets(offsets); + }, [model.columns, model.columnWidths]); + + function updateWidths(offsets: number[]) { + const widths = new Map(model.columnWidths.entries()); + for (let i = 0; i < offsets.length; ++i) { + const width = offsets[i] - (offsets[i - 1] || 0); + const column = model.columns[i]; + widths.set(column, width); + } + model.setColumnWidths(widths); } - const [offsets, setOffsets] = React.useState(initialOffsets); const toggleSorting = React.useCallback((f: keyof T) => { model.setSorting?.({ by: f, negate: model.sorting?.by === f ? !model.sorting.negate : false }); @@ -52,7 +67,7 @@ export function GridView(model: GridViewProps) { @@ -63,7 +78,7 @@ export function GridView(model: GridViewProps) { return
model.setSorting && toggleSorting(column)} > @@ -84,7 +99,9 @@ export function GridView(model: GridViewProps) { return
+ style={{ + width: i < model.columns.length - 1 ? model.columnWidths.get(column) : undefined, + }}> {body}
; })} From 9a11be3305cd6685ffbd63fe1a1e03679893ed82 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 30 May 2024 14:45:33 -0700 Subject: [PATCH 15/15] chore(trace-viewer): grid view z-index, source column in resource details (#31094) New look for multiple contexts: image --- packages/trace-viewer/src/ui/networkTab.tsx | 16 ++++++++++------ packages/web/src/components/gridView.tsx | 4 ++-- packages/web/src/shared/resizeView.tsx | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index ecab8cacf0..311af733a9 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -76,7 +76,7 @@ export const NetworkTab: React.FunctionComponent<{ return { renderedEntries }; }, [networkModel.resources, networkModel.contextIdMap, sorting, boundaries]); - const [widths, setWidths] = React.useState>(() => { + const [columnWidths, setColumnWidths] = React.useState>(() => { return new Map(allColumns().map(column => [column, columnWidth(column)])); }); @@ -91,8 +91,8 @@ export const NetworkTab: React.FunctionComponent<{ onHighlighted={item => onEntryHovered(item?.resource)} columns={visibleColumns(!!selectedEntry, renderedEntries)} columnTitle={columnTitle} - columnWidths={widths} - setColumnWidths={setWidths} + columnWidths={columnWidths} + setColumnWidths={setColumnWidths} isError={item => item.status.code >= 400} isInfo={item => !!item.route} render={(item, column) => renderCell(item, column)} @@ -101,7 +101,7 @@ export const NetworkTab: React.FunctionComponent<{ />; return <> {!selectedEntry && grid} - {selectedEntry && + {selectedEntry && setSelectedEntry(undefined)} /> {grid} } @@ -145,8 +145,12 @@ const columnWidth = (column: ColumnName) => { }; function visibleColumns(entrySelected: boolean, renderedEntries: RenderedEntry[]): (keyof RenderedEntry)[] { - if (entrySelected) - return ['name']; + if (entrySelected) { + const columns: (keyof RenderedEntry)[] = ['name']; + if (hasMultipleContexts(renderedEntries)) + columns.unshift('contextId'); + return columns; + } let columns: (keyof RenderedEntry)[] = allColumns(); if (!hasMultipleContexts(renderedEntries)) columns = columns.filter(name => name !== 'contextId'); diff --git a/packages/web/src/components/gridView.tsx b/packages/web/src/components/gridView.tsx index d5f0f905fb..43a40d55b4 100644 --- a/packages/web/src/components/gridView.tsx +++ b/packages/web/src/components/gridView.tsx @@ -49,7 +49,7 @@ export function GridView(model: GridViewProps) { setOffsets(offsets); }, [model.columns, model.columnWidths]); - function updateWidths(offsets: number[]) { + function updateColumnWidths(offsets: number[]) { const widths = new Map(model.columnWidths.entries()); for (let i = 0; i < offsets.length; ++i) { const width = offsets[i] - (offsets[i - 1] || 0); @@ -67,7 +67,7 @@ export function GridView(model: GridViewProps) { diff --git a/packages/web/src/shared/resizeView.tsx b/packages/web/src/shared/resizeView.tsx index e575866de0..401867f223 100644 --- a/packages/web/src/shared/resizeView.tsx +++ b/packages/web/src/shared/resizeView.tsx @@ -58,7 +58,7 @@ export const ResizeView: React.FC<{ right: 0, bottom: 0, left: -(7 - resizerWidth) / 2, - zIndex: 1000, + zIndex: 100, // Above the content, but below the film strip hover. pointerEvents: 'none', }} ref={ref}>