Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/config/browser/playwright.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,35 @@ await userEvent.click(page.getByRole('button'), {
timeout: 1_000,
})
```

## `persistentContext` <Version>4.1.0</Version> {#persistentcontext}

- **Type:** `boolean | string`
- **Default:** `false`

When enabled, Vitest uses Playwright's [persistent context](https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context) instead of a regular browser context. This allows browser state (cookies, localStorage, DevTools settings, etc.) to persist between test runs.

::: warning
This option is ignored when running tests in parallel (e.g. when headless with [`fileParallelism`](/config/fileparallelism) enalbed) since persistent context cannot be shared across parallel sessions.
:::

- When set to `true`, the user data is stored in `./node_modules/.cache/vitest-playwright-user-data`
- When set to a string, the value is used as the path to the user data directory

```ts [vitest.config.js]
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
browser: {
provider: playwright({
persistentContext: true,
// or specify a custom directory:
// persistentContext: './my-browser-data',
}),
instances: [{ browser: 'chromium' }],
},
},
})
```
84 changes: 66 additions & 18 deletions packages/browser-playwright/src/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ export interface PlaywrightProviderOptions {
* @default 0 (no timeout)
*/
actionTimeout?: number

/**
* Use a persistent context instead of a regular browser context.
* This allows browser state (cookies, localStorage, DevTools settings, etc.) to persist between test runs.
* When set to `true`, the user data is stored in `./node_modules/.cache/vitest-playwright-user-data`.
* When set to a string, the value is used as the path to the user data directory.
*
* Note: This option is ignored when running tests in parallel (e.g. headless with fileParallelism enabled)
* because persistent context cannot be shared across parallel sessions.
* @default false
* @see {@link https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context}
*/
persistentContext?: boolean | string
}

export function playwright(options: PlaywrightProviderOptions = {}): BrowserProviderOption<PlaywrightProviderOptions> {
Expand All @@ -94,6 +107,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
public supportsParallelism = true

public browser: Browser | null = null
public persistentContext: BrowserContext | null = null

public contexts: Map<string, BrowserContext> = new Map()
public pages: Map<string, Page> = new Map()
Expand Down Expand Up @@ -137,7 +151,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
})
}

private async openBrowser() {
private async openBrowser(openBrowserOptions: { parallel: boolean }) {
await this._throwIfClosing()

if (this.browserPromise) {
Expand Down Expand Up @@ -202,7 +216,31 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
}

debug?.('[%s] initializing the browser with launch options: %O', this.browserName, launchOptions)
this.browser = await playwright[this.browserName].launch(launchOptions)
let persistentContextOption = this.options.persistentContext
if (persistentContextOption && openBrowserOptions.parallel) {
persistentContextOption = false
this.project.vitest.logger.warn(
c.yellow(`The persistentContext option is ignored because tests are running in parallel.`),
)
}
if (persistentContextOption) {
const userDataDir
= typeof this.options.persistentContext === 'string'
? this.options.persistentContext
: './node_modules/.cache/vitest-playwright-user-data'
// TODO: how to avoid default "about" page?
this.persistentContext = await playwright[this.browserName].launchPersistentContext(
userDataDir,
{
...launchOptions,
...this.getContextOptions(),
},
)
this.browser = this.persistentContext.browser()!
}
else {
this.browser = await playwright[this.browserName].launch(launchOptions)
}
this.browserPromise = null
return this.browser
})()
Expand Down Expand Up @@ -346,31 +384,24 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
}
}

private async createContext(sessionId: string) {
private async createContext(sessionId: string, openBrowserOptions: { parallel: boolean }) {
await this._throwIfClosing()

if (this.contexts.has(sessionId)) {
debug?.('[%s][%s] the context already exists, reusing it', sessionId, this.browserName)
return this.contexts.get(sessionId)!
}

const browser = await this.openBrowser()
const browser = await this.openBrowser(openBrowserOptions)
await this._throwIfClosing(browser)
const actionTimeout = this.options.actionTimeout
const contextOptions = this.options.contextOptions ?? {}
const options = {
...contextOptions,
ignoreHTTPSErrors: true,
} satisfies BrowserContextOptions
if (this.project.config.browser.ui) {
options.viewport = null
}
const options = this.getContextOptions()
// TODO: investigate the consequences for Vitest 5
// else {
// if UI is disabled, keep the iframe scale to 1
// options.viewport ??= this.project.config.browser.viewport
// }
const context = await browser.newContext(options)
const context = this.persistentContext ?? await browser.newContext(options)
await this._throwIfClosing(context)
if (actionTimeout != null) {
context.setDefaultTimeout(actionTimeout)
Expand All @@ -380,6 +411,18 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
return context
}

private getContextOptions(): BrowserContextOptions {
const contextOptions = this.options.contextOptions ?? {}
const options = {
...contextOptions,
ignoreHTTPSErrors: true,
} satisfies BrowserContextOptions
if (this.project.config.browser.ui) {
options.viewport = null
}
return options
}

public getPage(sessionId: string): Page {
const page = this.pages.get(sessionId)
if (!page) {
Expand Down Expand Up @@ -421,7 +464,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
}
}

private async openBrowserPage(sessionId: string) {
private async openBrowserPage(sessionId: string, options: { parallel: boolean }) {
await this._throwIfClosing()

if (this.pages.has(sessionId)) {
Expand All @@ -431,7 +474,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
this.pages.delete(sessionId)
}

const context = await this.createContext(sessionId)
const context = await this.createContext(sessionId, options)
const page = await context.newPage()
debug?.('[%s][%s] the page is ready', sessionId, this.browserName)
await this._throwIfClosing(page)
Expand All @@ -453,9 +496,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
return page
}

async openPage(sessionId: string, url: string): Promise<void> {
async openPage(sessionId: string, url: string, options: { parallel: boolean }): Promise<void> {
debug?.('[%s][%s] creating the browser page for %s', sessionId, this.browserName, url)
const browserPage = await this.openBrowserPage(sessionId)
const browserPage = await this.openBrowserPage(sessionId, options)
debug?.('[%s][%s] browser page is created, opening %s', sessionId, this.browserName, url)
await browserPage.goto(url, { timeout: 0 })
await this._throwIfClosing(browserPage)
Expand Down Expand Up @@ -504,7 +547,12 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
this.browser = null
await Promise.all([...this.pages.values()].map(p => p.close()))
this.pages.clear()
await Promise.all([...this.contexts.values()].map(c => c.close()))
if (this.persistentContext) {
await this.persistentContext.close()
}
else {
await Promise.all([...this.contexts.values()].map(c => c.close()))
}
this.contexts.clear()
await browser?.close()
debug?.('[%s] provider is closed', this.browserName)
Expand Down
5 changes: 3 additions & 2 deletions packages/vitest/src/node/pools/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ class BrowserPool {
'vitest.browser.session_id': sessionId,
},
},
() => this.openPage(sessionId),
() => this.openPage(sessionId, { parallel: workerCount > 1 }),
)
page = page.then(() => {
// start running tests on the page when it's ready
Expand All @@ -275,7 +275,7 @@ class BrowserPool {
return this._promise
}

private async openPage(sessionId: string) {
private async openPage(sessionId: string, options: { parallel: boolean }): Promise<void> {
const sessionPromise = this.project.vitest._browserSessions.createSession(
sessionId,
this.project,
Expand All @@ -291,6 +291,7 @@ class BrowserPool {
const pagePromise = browser.provider.openPage(
sessionId,
url.toString(),
options,
)
await Promise.all([sessionPromise, pagePromise])
}
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/types/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface BrowserProvider {
*/
supportsParallelism: boolean
getCommandsContext: (sessionId: string) => Record<string, unknown>
openPage: (sessionId: string, url: string) => Promise<void>
openPage: (sessionId: string, url: string, options: { parallel: boolean }) => Promise<void>
getCDPSession?: (sessionId: string) => Promise<CDPSession>
close: () => Awaitable<void>
}
Expand Down
17 changes: 17 additions & 0 deletions test/config/fixtures/browser-persistent-context/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expect, test } from "vitest";

const expectedValue = import.meta.env.TEST_EXPECTED_VALUE || "0";

test(`expectedValue = ${expectedValue}`, () => {
// increment localStorage to test persistent context between test runs
const value = localStorage.getItem("test-persistent-context") || "0";
const nextValue = String(Number(value) + 1);
console.log(`localStorage: value = ${value}, nextValue = ${nextValue}`);
localStorage.setItem("test-persistent-context", nextValue);

const div = document.createElement("div");
div.textContent = `localStorage: value = ${value}, nextValue = ${nextValue}`;
document.body.appendChild(div);

expect(value).toBe(expectedValue)
});
20 changes: 20 additions & 0 deletions test/config/fixtures/browser-persistent-context/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { playwright } from "@vitest/browser-playwright";
import path from "node:path";
import { defineConfig } from "vitest/config";

export default defineConfig({
define: {
'import.meta.env.TEST_EXPECTED_VALUE': JSON.stringify(String(process.env.TEST_EXPECTED_VALUE)),
},
test: {
browser: {
enabled: true,
headless: true,
provider: playwright({
persistentContext: path.join(import.meta.dirname, "./node_modules/.cache/test-user-data"),
}),
instances: [{ browser: "chromium" }],
},
fileParallelism: false,
},
});
39 changes: 39 additions & 0 deletions test/config/test/browser-persistent-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { existsSync, rmSync } from 'node:fs'
import { resolve } from 'pathe'
import { expect, test } from 'vitest'
import { runVitest } from '../../test-utils'

test('persistent context works', async () => {
// clean user data dir
const root = resolve(import.meta.dirname, '../fixtures/browser-persistent-context')
const userDataDir = resolve(root, 'node_modules/.cache/test-user-data')
rmSync(userDataDir, { recursive: true, force: true })

// first run
process.env.TEST_EXPECTED_VALUE = '0'
const result1 = await runVitest({ root })
expect(result1.errorTree()).toMatchInlineSnapshot(`
{
"basic.test.ts": {
"expectedValue = 0": "passed",
},
}
`)
// check user data
expect(existsSync(userDataDir)).toBe(true)

// 2nd run
// localStorage is incremented during 1st run and
// 2nd run should pick that up from persistent context
process.env.TEST_EXPECTED_VALUE = '1'
const result2 = await runVitest({ root })
expect(result2.errorTree()).toMatchInlineSnapshot(`
{
"basic.test.ts": {
"expectedValue = 1": "passed",
},
}
`)
// check user data
expect(existsSync(userDataDir)).toBe(true)
Comment thread
hi-ogawa marked this conversation as resolved.
})
Loading