From 3059053e35ddab0f2ca93d401a3db1dbd806b15c Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Sat, 11 Oct 2025 10:26:44 -0700 Subject: [PATCH] change auth fixture to create it's own browser context and page. This is to allow closing of the page, but also to eventually enable multiple, independent, authenticated pages per test --- .../src/fixtures/auth.fixture.ts | 75 ++++++++++++++----- libs/playwright-helpers/src/scene.ts | 18 +++++ libs/playwright-helpers/src/test.ts | 8 +- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/libs/playwright-helpers/src/fixtures/auth.fixture.ts b/libs/playwright-helpers/src/fixtures/auth.fixture.ts index f6cb431d33a..ba0029f4b68 100644 --- a/libs/playwright-helpers/src/fixtures/auth.fixture.ts +++ b/libs/playwright-helpers/src/fixtures/auth.fixture.ts @@ -1,8 +1,11 @@ import * as fs from "fs"; import * as path from "path"; -import { Page, test } from "@playwright/test"; +import { Browser, Page, test } from "@playwright/test"; import { webServerBaseUrl } from "@playwright-config"; +import * as playwright from "playwright"; +// Playwright doesn't expose this type, so we duplicate it here +type BrowserName = "chromium" | "firefox" | "webkit"; import { Play, Scene, SingleUserRecipe } from "@bitwarden/playwright-helpers"; @@ -42,8 +45,44 @@ type AuthenticatedContext = { const AuthenticatedEmails = new Map(); export class AuthFixture { - constructor(private readonly page: Page) {} + private _browser!: Browser; + private _page!: Page; + constructor(private readonly browserName: BrowserName) {} + + async init(): Promise { + if (!this._browser) { + this._browser = await playwright[this.browserName].launch(); + } + } + + async close(): Promise { + if (this._browser) { + await this._browser.close(); + this._browser = undefined!; + } + } + + async page(): Promise { + if (!this._page) { + if (!this._browser) { + await this.init(); + } + const context = await this._browser.newContext(); + this._page = await context.newPage(); + } + return this._page; + } + + /** + * Creates a testing {@link Scene} with a user and a {@link Page} authenticated as that user. + * If the user has already been authenticated in this worker, it will reuse the existing session, + * but the pages are independent. + * + * @param email email of the user + * @param password password of the user + * @returns The authenticated page and scene used to scaffold the user + */ async authenticate(email: string, password: string): Promise { if (AuthenticatedEmails.has(email)) { return await this.resumeSession(email, password); @@ -54,6 +93,7 @@ export class AuthFixture { } async resumeSession(email: string, password: string): Promise { + const page = await this.page(); if (AuthenticatedEmails.get(email)!.password !== password) { throw new Error( `Email ${email} is already authenticated with a different password (${ @@ -63,50 +103,51 @@ export class AuthFixture { } const scene = AuthenticatedEmails.get(email)!.scene; const mangledEmail = scene.mangle(email); - await this.page.context().storageState({ path: dataFilePath(mangledEmail) }); + await page.context().storageState({ path: dataFilePath(mangledEmail) }); if (!fs.existsSync(sessionFilePath(mangledEmail))) { throw new Error("No session file found"); } // Load stored state and session into a new page - await loadLocal(this.page, mangledEmail); - await loadSession(this.page, mangledEmail); + await loadLocal(page, mangledEmail); + await loadSession(page, mangledEmail); - await this.page.goto("/#/"); + await page.goto("/#/"); return { - page: this.page, + page, scene, }; } async newSession(email: string, password: string): Promise { + const page = await this.page(); using scene = await Play.scene(new SingleUserRecipe({ email }), { downAfterAll: true, }); const mangledEmail = scene.mangle(email); - await this.page.goto("/#/login"); + await page.goto("/#/login"); - await this.page + await page .getByRole("textbox", { name: "Email address (required)" }) .fill(scene.mangle("test@example.com")); - await this.page.getByRole("textbox", { name: "Email address (required)" }).press("Enter"); - await this.page + await page.getByRole("textbox", { name: "Email address (required)" }).press("Enter"); + await page .getByRole("textbox", { name: "Master password (required)" }) .fill(scene.mangle("asdfasdfasdf")); - await this.page.getByRole("button", { name: "Log in with master password" }).click(); - await this.page.getByRole("button", { name: "Add it later" }).click(); - await this.page.getByRole("link", { name: "Skip to web app" }).click(); + await page.getByRole("button", { name: "Log in with master password" }).click(); + await page.getByRole("button", { name: "Add it later" }).click(); + await page.getByRole("link", { name: "Skip to web app" }).click(); // Store the scene for future use AuthenticatedEmails.set(email, { email, password, scene }); // Save storage state to avoid logging in again - await saveLocal(this.page, mangledEmail); - await saveSession(this.page, mangledEmail); + await saveLocal(page, mangledEmail); + await saveSession(page, mangledEmail); - return { page: this.page, scene }; + return { page, scene }; } } diff --git a/libs/playwright-helpers/src/scene.ts b/libs/playwright-helpers/src/scene.ts index 89eb7bc2c93..bac5d076a23 100644 --- a/libs/playwright-helpers/src/scene.ts +++ b/libs/playwright-helpers/src/scene.ts @@ -42,7 +42,12 @@ export class Scene implements UsingRequired { * @returns The scene instance for chaining */ noDown(): this { + if (process.env.CI) { + throw new Error("Cannot set noDown to true in CI environments"); + } + seedIdsToTearDown.delete(this.seedId); + seedIdsToWarnAbout.add(this.seedId); this.options.noDown = true; return this; } @@ -151,10 +156,15 @@ export class Play { options: SceneOptions = {}, ): Promise { const opts = { ...SCENE_OPTIONS_DEFAULTS, ...options }; + if (opts.noDown && process.env.CI) { + throw new Error("Cannot set noDown to true in CI environments"); + } const scene = new Scene(opts); await scene.init(recipe); if (!opts.noDown) { seedIdsToTearDown.add(scene.seedId); + } else { + seedIdsToWarnAbout.add(scene.seedId); } return scene; } @@ -171,9 +181,17 @@ export class Play { } const seedIdsToTearDown = new Set(); +const seedIdsToWarnAbout = new Set(); // After all tests complete test.afterAll(async () => { + if (seedIdsToWarnAbout.size > 0) { + // eslint-disable-next-line no-console + console.warn( + "Some scenes were not torn down. To tear them down manually run:\n", + `curl -X DELETE -H 'Content-Type: application/json' -d '${JSON.stringify(Array.from(seedIdsToWarnAbout))}' ${new URL("batch", seedApiUrl).toString()}\n`, + ); + } const response = await fetch(new URL("batch", seedApiUrl).toString(), { method: "DELETE", headers: { diff --git a/libs/playwright-helpers/src/test.ts b/libs/playwright-helpers/src/test.ts index fec61128e3a..d721fd40475 100644 --- a/libs/playwright-helpers/src/test.ts +++ b/libs/playwright-helpers/src/test.ts @@ -7,8 +7,12 @@ interface TestParams { } export const test = base.extend({ - auth: async ({ page }, use) => { - const authedPage = new AuthFixture(page); + auth: async ({ browserName }, use) => { + const authedPage = new AuthFixture(browserName); + await authedPage.init(); + await use(authedPage); + + await authedPage.close(); }, });