From cdec1a932439c467b386fcc1ce0408f69ca7f5ed Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 12 Jun 2024 09:25:25 -0700 Subject: [PATCH] feat(clock): expire cookies naively --- docs/src/api/class-clock.md | 16 ++++++++ packages/playwright-core/src/client/clock.ts | 10 +++-- .../playwright-core/src/protocol/validator.ts | 1 + .../src/server/browserContext.ts | 2 +- packages/playwright-core/src/server/clock.ts | 34 ++++++++++++++-- .../dispatchers/browserContextDispatcher.ts | 2 +- packages/playwright-core/types/types.d.ts | 15 +++++++ packages/protocol/src/channels.ts | 2 + packages/protocol/src/protocol.yml | 1 + tests/page/page-clock.spec.ts | 39 +++++++++++++++++++ 10 files changed, 112 insertions(+), 10 deletions(-) diff --git a/docs/src/api/class-clock.md b/docs/src/api/class-clock.md index 9099d33a4f..f03f8888db 100644 --- a/docs/src/api/class-clock.md +++ b/docs/src/api/class-clock.md @@ -69,6 +69,22 @@ Fake timers are used to manually control the flow of time in tests. They allow y Time to initialize with, current system time by default. +### option: Clock.install.expireCookies +* since: v1.45 +- `expireCookies` <[boolean]> + +Whether to simulate cookies' expiration over time as clock is fast forwarded. +Requires clock to be installed with current time, i.e. [`option: time`] should not be specified. Defaults to `false`. + +**Details** + +Cookies are managed by the network stack, which is a part of the operating system in case of macOS. +Playwright only simulates the browser context time, so the network is largely unaware of the changes. + +Passing this option will make Playwright simulate the cookie expiration over time via shifting its +expiration date into the past. Note that getting and setting cookies of the [BrowserContext] will +return raw cookie values as in the cookie jar. + ## async method: Clock.runFor * since: v1.45 diff --git a/packages/playwright-core/src/client/clock.ts b/packages/playwright-core/src/client/clock.ts index 32faa7a4d4..ca2df312b4 100644 --- a/packages/playwright-core/src/client/clock.ts +++ b/packages/playwright-core/src/client/clock.ts @@ -24,8 +24,8 @@ export class Clock implements api.Clock { this._browserContext = browserContext; } - async install(options: { time?: number | string | Date } = { }) { - await this._browserContext._channel.clockInstall(options.time !== undefined ? parseTime(options.time) : {}); + async install(options: { time?: number | string | Date, expireCookies?: boolean } = { }) { + await this._browserContext._channel.clockInstall({ ...parseTime(options.time), expireCookies: options.expireCookies }); } async fastForward(ticks: number | string) { @@ -53,12 +53,14 @@ export class Clock implements api.Clock { } } -function parseTime(time: string | number | Date): { timeNumber?: number, timeString?: string } { +function parseTime(time: string | number | Date | undefined): { timeNumber?: number, timeString?: string } { if (typeof time === 'number') return { timeNumber: time }; if (typeof time === 'string') return { timeString: time }; - return { timeNumber: time.getTime() }; + if (time) + return { timeNumber: time.getTime() }; + return {}; } function parseTicks(ticks: string | number): { ticksNumber?: number, ticksString?: string } { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 5727192b59..22f093aa23 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -975,6 +975,7 @@ scheme.BrowserContextClockFastForwardResult = tOptional(tObject({})); scheme.BrowserContextClockInstallParams = tObject({ timeNumber: tOptional(tNumber), timeString: tOptional(tString), + expireCookies: tOptional(tBoolean), }); scheme.BrowserContextClockInstallResult = tOptional(tObject({})); scheme.BrowserContextClockPauseAtParams = tObject({ diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index c1d70372a2..161dd945ac 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -279,7 +279,7 @@ export abstract class BrowserContext extends SdkObject { return await this.doGetCookies(urls as string[]); } - async clearCookies(options: {name?: string | RegExp, domain?: string | RegExp, path?: string | RegExp}): Promise { + async clearCookies(options: {name?: string | RegExp, domain?: string | RegExp, path?: string | RegExp} = {}): Promise { const currentCookies = await this.cookies(); await this.doClearCookies(); diff --git a/packages/playwright-core/src/server/clock.ts b/packages/playwright-core/src/server/clock.ts index 58a1f72720..5981f29622 100644 --- a/packages/playwright-core/src/server/clock.ts +++ b/packages/playwright-core/src/server/clock.ts @@ -21,6 +21,7 @@ import { isJavaScriptErrorInEvaluate } from './javascript'; export class Clock { private _browserContext: BrowserContext; private _scriptInstalled = false; + private _expireCookies = false; constructor(browserContext: BrowserContext) { this._browserContext = browserContext; @@ -31,11 +32,15 @@ export class Clock { const ticksMillis = parseTicks(ticks); await this._browserContext.addInitScript(`globalThis.__pwClock.controller.log('fastForward', ${Date.now()}, ${ticksMillis})`); await this._evaluateInFrames(`globalThis.__pwClock.controller.fastForward(${ticksMillis})`); + await this._expireCookiesIfNeeded(ticksMillis); } - async install(time: number | string | undefined) { + async install(time: number | string | undefined, expireCookies: boolean) { await this._installIfNeeded(); const timeMillis = time !== undefined ? parseTime(time) : Date.now(); + this._expireCookies = expireCookies; + if (time !== undefined && expireCookies) + throw new Error('Cannot expire cookies when installing the clock with the non-current time'); await this._browserContext.addInitScript(`globalThis.__pwClock.controller.log('install', ${Date.now()}, ${timeMillis})`); await this._evaluateInFrames(`globalThis.__pwClock.controller.install(${timeMillis})`); } @@ -44,7 +49,8 @@ export class Clock { await this._installIfNeeded(); const timeMillis = parseTime(ticks); await this._browserContext.addInitScript(`globalThis.__pwClock.controller.log('pauseAt', ${Date.now()}, ${timeMillis})`); - await this._evaluateInFrames(`globalThis.__pwClock.controller.pauseAt(${timeMillis})`); + const consumedTicks = await this._evaluateInFrames(`globalThis.__pwClock.controller.pauseAt(${timeMillis})`); + this._expireCookiesIfNeeded(consumedTicks); } async resume() { @@ -64,7 +70,8 @@ export class Clock { await this._installIfNeeded(); const timeMillis = parseTime(time); await this._browserContext.addInitScript(`globalThis.__pwClock.controller.log('setSystemTime', ${Date.now()}, ${timeMillis})`); - await this._evaluateInFrames(`globalThis.__pwClock.controller.setSystemTime(${timeMillis})`); + const consumedTicks = await this._evaluateInFrames(`globalThis.__pwClock.controller.setSystemTime(${timeMillis})`); + this._expireCookiesIfNeeded(consumedTicks); } async runFor(ticks: number | string) { @@ -72,6 +79,7 @@ export class Clock { const ticksMillis = parseTicks(ticks); await this._browserContext.addInitScript(`globalThis.__pwClock.controller.log('runFor', ${Date.now()}, ${ticksMillis})`); await this._evaluateInFrames(`globalThis.__pwClock.controller.runFor(${ticksMillis})`); + await this._expireCookiesIfNeeded(ticksMillis); } private async _installIfNeeded() { @@ -95,7 +103,7 @@ export class Clock { const frames = this._browserContext.pages().map(page => page.frames()).flat(); const results = await Promise.all(frames.map(async frame => { try { - await frame.nonStallingEvaluateInExistingContext(script, false, 'main'); + return await frame.nonStallingEvaluateInExistingContext(script, false, 'main'); } catch (e) { if (isJavaScriptErrorInEvaluate(e)) throw e; @@ -103,8 +111,26 @@ export class Clock { })); return results[0]; } + + private async _expireCookiesIfNeeded(ticks: number) { + if (!this._expireCookies) + return; + const cookies = await this._browserContext.cookies(); + let dirty = false; + for (const cookie of cookies) { + if (cookie.expires > 0) { + cookie.expires -= ticks / 1000; + dirty = true; + } + } + if (dirty) { + await this._browserContext.clearCookies(); + await this._browserContext.addCookies(cookies); + } + } } + /** * 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 diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 5654950360..a7af94a44e 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -324,7 +324,7 @@ export class BrowserContextDispatcher extends Dispatcher { - await this._context.clock.install(params.timeString ?? params.timeNumber ?? undefined); + await this._context.clock.install(params.timeString ?? params.timeNumber ?? undefined, !!params.expireCookies); } async clockPauseAt(params: channels.BrowserContextClockPauseAtParams, metadata?: CallMetadata | undefined): Promise { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 43a89fbad2..aabb917f09 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -17286,6 +17286,21 @@ export interface Clock { * @param options */ install(options?: { + /** + * Whether to simulate cookies' expiration over time as clock is fast forwarded. Requires clock to be installed with + * current time, i.e. `time` should not be specified. Defaults to `false`. + * + * **Details** + * + * Cookies are managed by the network stack, which is a part of the operating system in case of macOS. Playwright only + * simulates the browser context time, so the network is largely unaware of the changes. + * + * Passing this option will make Playwright simulate the cookie expiration over time via shifting its expiration date + * into the past. Note that getting and setting cookies of the {@link BrowserContext} will return raw cookie values as + * in the cookie jar. + */ + expireCookies?: boolean; + /** * Time to initialize with, current system time by default. */ diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 13d34f1f51..b93082604f 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1771,10 +1771,12 @@ export type BrowserContextClockFastForwardResult = void; export type BrowserContextClockInstallParams = { timeNumber?: number, timeString?: string, + expireCookies?: boolean, }; export type BrowserContextClockInstallOptions = { timeNumber?: number, timeString?: string, + expireCookies?: boolean, }; export type BrowserContextClockInstallResult = void; export type BrowserContextClockPauseAtParams = { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 2bef7c8762..aa3668a58a 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1222,6 +1222,7 @@ BrowserContext: parameters: timeNumber: number? timeString: string? + expireCookies: boolean? clockPauseAt: parameters: diff --git a/tests/page/page-clock.spec.ts b/tests/page/page-clock.spec.ts index 670891a40a..3af209d024 100644 --- a/tests/page/page-clock.spec.ts +++ b/tests/page/page-clock.spec.ts @@ -533,3 +533,42 @@ it.describe('while on pause', () => { expect(calls).toEqual([{ params: ['outer'] }, { params: ['inner'] }]); }); }); + +it('expireCookies should throw for non-current time', async ({ page }) => { + await expect(page.clock.install({ time: 0, expireCookies: true })).rejects.toThrow(); +}); + + +it.describe('cookies', () => { + it.beforeEach(async ({ page, server }) => { + await page.clock.install({ expireCookies: true }); + await page.goto(server.EMPTY_PAGE); + const date = Date.now() + 1000 * 60 * 60 * 24; + await page.evaluate(timestamp => { + const date = new Date(timestamp); + document.cookie = `username=John Doe;expires=${date.toUTCString()}`; + return document.cookie; + }, date); + expect(await page.evaluate(() => document.cookie)).toBe('username=John Doe'); + }); + + it('expire with fastForward', async ({ page }) => { + await page.clock.fastForward(1000 * 60 * 60 * 23); + expect(await page.evaluate(() => document.cookie)).toBe('username=John Doe'); + await page.clock.fastForward(1000 * 60 * 60 * 1); + expect(await page.evaluate(() => document.cookie)).toBe(''); + }); + + it('expire with runFor', async ({ page }) => { + await page.clock.fastForward(1000 * 60 * 60 * 23.9); + expect(await page.evaluate(() => document.cookie)).toBe('username=John Doe'); + await page.clock.runFor(1000 * 60 * 60 * 0.1); + expect(await page.evaluate(() => document.cookie)).toBe(''); + }); + + it('expire with pauseAt', async ({ page }) => { + const now = await page.evaluate(() => Date.now()); + await page.clock.pauseAt(now + 1000 * 60 * 60 * 24); + await expect.poll(async () => await page.evaluate(() => document.cookie)).toBe(''); + }); +});