[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(browser): support playwright persistent context (#9229)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Vladimir <sleuths.slews0s@icloud.com>

authored by

Hiroshi Ogawa
Claude Opus 4.5
Vladimir
and committed by
GitHub
(Jan 19, 2026, 11:16 AM +0100) f865d2ba 200f3170

+182 -25
+32
docs/config/browser/playwright.md
··· 104 104 timeout: 1_000, 105 105 }) 106 106 ``` 107 + 108 + ## `persistentContext` <Version>4.1.0</Version> {#persistentcontext} 109 + 110 + - **Type:** `boolean | string` 111 + - **Default:** `false` 112 + 113 + 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. 114 + 115 + ::: warning 116 + 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. 117 + ::: 118 + 119 + - When set to `true`, the user data is stored in `./node_modules/.cache/vitest-playwright-user-data` 120 + - When set to a string, the value is used as the path to the user data directory 121 + 122 + ```ts [vitest.config.js] 123 + import { playwright } from '@vitest/browser-playwright' 124 + import { defineConfig } from 'vitest/config' 125 + 126 + export default defineConfig({ 127 + test: { 128 + browser: { 129 + provider: playwright({ 130 + persistentContext: true, 131 + // or specify a custom directory: 132 + // persistentContext: './my-browser-data', 133 + }), 134 + instances: [{ browser: 'chromium' }], 135 + }, 136 + }, 137 + }) 138 + ```
+70 -22
packages/browser-playwright/src/playwright.ts
··· 76 76 * @default 0 (no timeout) 77 77 */ 78 78 actionTimeout?: number 79 + 80 + /** 81 + * Use a persistent context instead of a regular browser context. 82 + * This allows browser state (cookies, localStorage, DevTools settings, etc.) to persist between test runs. 83 + * When set to `true`, the user data is stored in `./node_modules/.cache/vitest-playwright-user-data`. 84 + * When set to a string, the value is used as the path to the user data directory. 85 + * 86 + * Note: This option is ignored when running tests in parallel (e.g. headless with fileParallelism enabled) 87 + * because persistent context cannot be shared across parallel sessions. 88 + * @default false 89 + * @see {@link https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context} 90 + */ 91 + persistentContext?: boolean | string 79 92 } 80 93 81 94 export function playwright(options: PlaywrightProviderOptions = {}): BrowserProviderOption<PlaywrightProviderOptions> { ··· 94 107 public supportsParallelism = true 95 108 96 109 public browser: Browser | null = null 110 + public persistentContext: BrowserContext | null = null 97 111 98 112 public contexts: Map<string, BrowserContext> = new Map() 99 113 public pages: Map<string, Page> = new Map() ··· 137 151 }) 138 152 } 139 153 140 - private async openBrowser() { 154 + private async openBrowser(openBrowserOptions: { parallel: boolean }) { 141 155 await this._throwIfClosing() 142 156 143 157 if (this.browserPromise) { ··· 202 216 } 203 217 204 218 debug?.('[%s] initializing the browser with launch options: %O', this.browserName, launchOptions) 205 - this.browser = await playwright[this.browserName].launch(launchOptions) 219 + let persistentContextOption = this.options.persistentContext 220 + if (persistentContextOption && openBrowserOptions.parallel) { 221 + persistentContextOption = false 222 + this.project.vitest.logger.warn( 223 + c.yellow(`The persistentContext option is ignored because tests are running in parallel.`), 224 + ) 225 + } 226 + if (persistentContextOption) { 227 + const userDataDir 228 + = typeof this.options.persistentContext === 'string' 229 + ? this.options.persistentContext 230 + : './node_modules/.cache/vitest-playwright-user-data' 231 + // TODO: how to avoid default "about" page? 232 + this.persistentContext = await playwright[this.browserName].launchPersistentContext( 233 + userDataDir, 234 + { 235 + ...launchOptions, 236 + ...this.getContextOptions(), 237 + }, 238 + ) 239 + this.browser = this.persistentContext.browser()! 240 + } 241 + else { 242 + this.browser = await playwright[this.browserName].launch(launchOptions) 243 + } 206 244 this.browserPromise = null 207 245 return this.browser 208 246 })() ··· 346 384 } 347 385 } 348 386 349 - private async createContext(sessionId: string) { 387 + private async createContext(sessionId: string, openBrowserOptions: { parallel: boolean }) { 350 388 await this._throwIfClosing() 351 389 352 390 if (this.contexts.has(sessionId)) { ··· 354 392 return this.contexts.get(sessionId)! 355 393 } 356 394 357 - const browser = await this.openBrowser() 395 + const browser = await this.openBrowser(openBrowserOptions) 358 396 await this._throwIfClosing(browser) 359 397 const actionTimeout = this.options.actionTimeout 398 + const options = this.getContextOptions() 399 + // TODO: investigate the consequences for Vitest 5 400 + // else { 401 + // if UI is disabled, keep the iframe scale to 1 402 + // options.viewport ??= this.project.config.browser.viewport 403 + // } 404 + const context = this.persistentContext ?? await browser.newContext(options) 405 + await this._throwIfClosing(context) 406 + if (actionTimeout != null) { 407 + context.setDefaultTimeout(actionTimeout) 408 + } 409 + debug?.('[%s][%s] the context is ready', sessionId, this.browserName) 410 + this.contexts.set(sessionId, context) 411 + return context 412 + } 413 + 414 + private getContextOptions(): BrowserContextOptions { 360 415 const contextOptions = this.options.contextOptions ?? {} 361 416 const options = { 362 417 ...contextOptions, ··· 365 420 if (this.project.config.browser.ui) { 366 421 options.viewport = null 367 422 } 368 - // TODO: investigate the consequences for Vitest 5 369 - // else { 370 - // if UI is disabled, keep the iframe scale to 1 371 - // options.viewport ??= this.project.config.browser.viewport 372 - // } 373 - const context = await browser.newContext(options) 374 - await this._throwIfClosing(context) 375 - if (actionTimeout != null) { 376 - context.setDefaultTimeout(actionTimeout) 377 - } 378 - debug?.('[%s][%s] the context is ready', sessionId, this.browserName) 379 - this.contexts.set(sessionId, context) 380 - return context 423 + return options 381 424 } 382 425 383 426 public getPage(sessionId: string): Page { ··· 421 464 } 422 465 } 423 466 424 - private async openBrowserPage(sessionId: string) { 467 + private async openBrowserPage(sessionId: string, options: { parallel: boolean }) { 425 468 await this._throwIfClosing() 426 469 427 470 if (this.pages.has(sessionId)) { ··· 431 474 this.pages.delete(sessionId) 432 475 } 433 476 434 - const context = await this.createContext(sessionId) 477 + const context = await this.createContext(sessionId, options) 435 478 const page = await context.newPage() 436 479 debug?.('[%s][%s] the page is ready', sessionId, this.browserName) 437 480 await this._throwIfClosing(page) ··· 453 496 return page 454 497 } 455 498 456 - async openPage(sessionId: string, url: string): Promise<void> { 499 + async openPage(sessionId: string, url: string, options: { parallel: boolean }): Promise<void> { 457 500 debug?.('[%s][%s] creating the browser page for %s', sessionId, this.browserName, url) 458 - const browserPage = await this.openBrowserPage(sessionId) 501 + const browserPage = await this.openBrowserPage(sessionId, options) 459 502 debug?.('[%s][%s] browser page is created, opening %s', sessionId, this.browserName, url) 460 503 await browserPage.goto(url, { timeout: 0 }) 461 504 await this._throwIfClosing(browserPage) ··· 504 547 this.browser = null 505 548 await Promise.all([...this.pages.values()].map(p => p.close())) 506 549 this.pages.clear() 507 - await Promise.all([...this.contexts.values()].map(c => c.close())) 550 + if (this.persistentContext) { 551 + await this.persistentContext.close() 552 + } 553 + else { 554 + await Promise.all([...this.contexts.values()].map(c => c.close())) 555 + } 508 556 this.contexts.clear() 509 557 await browser?.close() 510 558 debug?.('[%s] provider is closed', this.browserName)
+39
test/config/test/browser-persistent-context.test.ts
··· 1 + import { existsSync, rmSync } from 'node:fs' 2 + import { resolve } from 'pathe' 3 + import { expect, test } from 'vitest' 4 + import { runVitest } from '../../test-utils' 5 + 6 + test('persistent context works', async () => { 7 + // clean user data dir 8 + const root = resolve(import.meta.dirname, '../fixtures/browser-persistent-context') 9 + const userDataDir = resolve(root, 'node_modules/.cache/test-user-data') 10 + rmSync(userDataDir, { recursive: true, force: true }) 11 + 12 + // first run 13 + process.env.TEST_EXPECTED_VALUE = '0' 14 + const result1 = await runVitest({ root }) 15 + expect(result1.errorTree()).toMatchInlineSnapshot(` 16 + { 17 + "basic.test.ts": { 18 + "expectedValue = 0": "passed", 19 + }, 20 + } 21 + `) 22 + // check user data 23 + expect(existsSync(userDataDir)).toBe(true) 24 + 25 + // 2nd run 26 + // localStorage is incremented during 1st run and 27 + // 2nd run should pick that up from persistent context 28 + process.env.TEST_EXPECTED_VALUE = '1' 29 + const result2 = await runVitest({ root }) 30 + expect(result2.errorTree()).toMatchInlineSnapshot(` 31 + { 32 + "basic.test.ts": { 33 + "expectedValue = 1": "passed", 34 + }, 35 + } 36 + `) 37 + // check user data 38 + expect(existsSync(userDataDir)).toBe(true) 39 + })
+17
test/config/fixtures/browser-persistent-context/basic.test.ts
··· 1 + import { expect, test } from "vitest"; 2 + 3 + const expectedValue = import.meta.env.TEST_EXPECTED_VALUE || "0"; 4 + 5 + test(`expectedValue = ${expectedValue}`, () => { 6 + // increment localStorage to test persistent context between test runs 7 + const value = localStorage.getItem("test-persistent-context") || "0"; 8 + const nextValue = String(Number(value) + 1); 9 + console.log(`localStorage: value = ${value}, nextValue = ${nextValue}`); 10 + localStorage.setItem("test-persistent-context", nextValue); 11 + 12 + const div = document.createElement("div"); 13 + div.textContent = `localStorage: value = ${value}, nextValue = ${nextValue}`; 14 + document.body.appendChild(div); 15 + 16 + expect(value).toBe(expectedValue) 17 + });
+20
test/config/fixtures/browser-persistent-context/vitest.config.ts
··· 1 + import { playwright } from "@vitest/browser-playwright"; 2 + import path from "node:path"; 3 + import { defineConfig } from "vitest/config"; 4 + 5 + export default defineConfig({ 6 + define: { 7 + 'import.meta.env.TEST_EXPECTED_VALUE': JSON.stringify(String(process.env.TEST_EXPECTED_VALUE)), 8 + }, 9 + test: { 10 + browser: { 11 + enabled: true, 12 + headless: true, 13 + provider: playwright({ 14 + persistentContext: path.join(import.meta.dirname, "./node_modules/.cache/test-user-data"), 15 + }), 16 + instances: [{ browser: "chromium" }], 17 + }, 18 + fileParallelism: false, 19 + }, 20 + });
+3 -2
packages/vitest/src/node/pools/browser.ts
··· 262 262 'vitest.browser.session_id': sessionId, 263 263 }, 264 264 }, 265 - () => this.openPage(sessionId), 265 + () => this.openPage(sessionId, { parallel: workerCount > 1 }), 266 266 ) 267 267 page = page.then(() => { 268 268 // start running tests on the page when it's ready ··· 275 275 return this._promise 276 276 } 277 277 278 - private async openPage(sessionId: string) { 278 + private async openPage(sessionId: string, options: { parallel: boolean }): Promise<void> { 279 279 const sessionPromise = this.project.vitest._browserSessions.createSession( 280 280 sessionId, 281 281 this.project, ··· 291 291 const pagePromise = browser.provider.openPage( 292 292 sessionId, 293 293 url.toString(), 294 + options, 294 295 ) 295 296 await Promise.all([sessionPromise, pagePromise]) 296 297 }
+1 -1
packages/vitest/src/node/types/browser.ts
··· 50 50 */ 51 51 supportsParallelism: boolean 52 52 getCommandsContext: (sessionId: string) => Record<string, unknown> 53 - openPage: (sessionId: string, url: string) => Promise<void> 53 + openPage: (sessionId: string, url: string, options: { parallel: boolean }) => Promise<void> 54 54 getCDPSession?: (sessionId: string) => Promise<CDPSession> 55 55 close: () => Awaitable<void> 56 56 }