feat(clock): expire cookies naively

This commit is contained in:
Pavel Feldman 2024-06-12 09:25:25 -07:00
parent 897f7449ef
commit cdec1a9324
10 changed files with 112 additions and 10 deletions

View file

@ -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

View file

@ -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 } {

View file

@ -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({

View file

@ -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<void> {
async clearCookies(options: {name?: string | RegExp, domain?: string | RegExp, path?: string | RegExp} = {}): Promise<void> {
const currentCookies = await this.cookies();
await this.doClearCookies();

View file

@ -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

View file

@ -324,7 +324,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
}
async clockInstall(params: channels.BrowserContextClockInstallParams, metadata?: CallMetadata | undefined): Promise<channels.BrowserContextClockInstallResult> {
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<channels.BrowserContextClockPauseAtResult> {

View file

@ -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.
*/

View file

@ -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 = {

View file

@ -1222,6 +1222,7 @@ BrowserContext:
parameters:
timeNumber: number?
timeString: string?
expireCookies: boolean?
clockPauseAt:
parameters:

View file

@ -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('');
});
});