From 06e6600f6aceffe02e7a128a0a568352739d135d Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Mon, 10 Nov 2025 18:10:31 -0800 Subject: [PATCH] Use playId to track all creations within a worker. This simplifies scenes and allows tracking of entities created during tests as well as those set up for tests. However, we need to override the browser's `fetch` method to accomplish this. I have added a page extension that does this, but it is possible to create pages from browser contexts, contexts from browsers and even browsers from browser names. We simply need to expect imperfect coverage on this header. --- .../registration/registration.play.spec.ts | 35 +++++ .../emergency-access.play.spec.ts | 12 +- .../src/app/auth/login/example.play.spec.ts | 2 +- .../src/fixtures/auth.fixture.ts | 4 +- .../src/fixtures/page-extension.ts | 29 +++++ .../src/fixtures/user-state.fixture.ts | 10 +- libs/playwright-helpers/src/play.ts | 56 ++++---- libs/playwright-helpers/src/scene.ts | 121 +----------------- libs/playwright-helpers/src/test.ts | 73 +++++++++++ playwright.config.ts | 2 +- 10 files changed, 189 insertions(+), 155 deletions(-) create mode 100644 apps/web/src/app/auth/core/services/registration/registration.play.spec.ts create mode 100644 libs/playwright-helpers/src/fixtures/page-extension.ts diff --git a/apps/web/src/app/auth/core/services/registration/registration.play.spec.ts b/apps/web/src/app/auth/core/services/registration/registration.play.spec.ts new file mode 100644 index 00000000000..12f3fd2c002 --- /dev/null +++ b/apps/web/src/app/auth/core/services/registration/registration.play.spec.ts @@ -0,0 +1,35 @@ +import { expect } from "@playwright/test"; + +import { Play, test } from "@bitwarden/playwright-helpers"; + +test.only("test", async ({ page }) => { + await page.goto("https://localhost:8080/#/signup"); + + await page + .getByRole("textbox", { name: "Email address (required)" }) + .fill(Play.mangleEmail("create@test.com")); + await page.getByRole("textbox", { name: "Name" }).fill("John Doe"); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("textbox", { name: "Master password (required)", exact: true }) + .fill("asdfasdfasdf"); + await page.getByRole("textbox", { name: "Confirm master password (" }).fill("asdfasdfasdf"); + + await page.getByRole("textbox", { name: "Master password hint" }).fill("asdfasdfasdf"); + await page.getByRole("button", { name: "Create account" }).click(); + await expect(page.locator("#bit-error-0")).toContainText( + "Your password hint cannot be the same as your password.", + ); + await page + .getByRole("textbox", { name: "Master password hint" }) + .fill("the hint for the password"); + + await page.getByRole("checkbox", { name: "Check known data breaches for" }).uncheck(); + await page.getByRole("button", { name: "Create account" }).click(); + await expect(page.locator("#bit-dialog-title-0")).toContainText( + "Weak password identified. Use a strong password to protect your account. Are you sure you want to use a weak password?", + ); + await page.getByRole("button", { name: "Yes" }).click(); + await page.getByRole("button", { name: "Add it later" }).click(); + await page.getByRole("link", { name: "Skip to web app" }).click(); +}); diff --git a/apps/web/src/app/auth/emergency-access/emergency-access.play.spec.ts b/apps/web/src/app/auth/emergency-access/emergency-access.play.spec.ts index 6c66c04cc3e..50da0d7cf3c 100644 --- a/apps/web/src/app/auth/emergency-access/emergency-access.play.spec.ts +++ b/apps/web/src/app/auth/emergency-access/emergency-access.play.spec.ts @@ -1,14 +1,15 @@ import { test as base, expect, Page } from "@playwright/test"; import { - EmergencyAccessInviteRecipe, + EmergencyAccessInviteQuery, Play, Scene, - SingleUserRecipe, + SingleUserSceneTemplate, } from "@bitwarden/playwright-helpers"; +import { UserId } from "@bitwarden/user-core"; async function authenticate(page: Page, email: string) { - using scene = await Play.scene(new SingleUserRecipe({ email, premium: true }), { noDown: true }); + const scene = await Play.scene(new SingleUserSceneTemplate({ email, premium: true })); await page.goto("/#/login"); await page.getByRole("textbox", { name: "Email address (required)" }).click(); @@ -37,7 +38,7 @@ type MyFixtures = { type MyFixture2 = { page: Page; - scene: Scene; + scene: Scene; }; const test = base.extend({ @@ -74,8 +75,7 @@ base.describe("Emergency Access", () => { await expect(await grantor.page.getByRole("cell", { name: granteeEmail })).toBeVisible(); // Grab the invite link from the server directly since intercepting email is hard - const recipe = new EmergencyAccessInviteRecipe({ email: granteeEmail }); - const result = (await recipe.up()) as unknown as string[]; // FIXME: Recipe does not only return mangle map. + const result = await Play.query(new EmergencyAccessInviteQuery({ email: granteeEmail })); const inviteUrl = result[0]; await grantee.page.goto(`/#${inviteUrl}`); diff --git a/apps/web/src/app/auth/login/example.play.spec.ts b/apps/web/src/app/auth/login/example.play.spec.ts index fdb471b0ec6..5488e65595e 100644 --- a/apps/web/src/app/auth/login/example.play.spec.ts +++ b/apps/web/src/app/auth/login/example.play.spec.ts @@ -3,7 +3,7 @@ import { expect } from "@playwright/test"; import { Play, SingleUserSceneTemplate, test } from "@bitwarden/playwright-helpers"; test("login with password", async ({ page }) => { - using scene = await Play.scene(new SingleUserSceneTemplate({ email: "test@example.com" })); + const scene = await Play.scene(new SingleUserSceneTemplate({ email: "test@example.com" })); await page.goto("https://localhost:8080/#/login"); await page.getByRole("textbox", { name: "Email address (required)" }).click(); diff --git a/libs/playwright-helpers/src/fixtures/auth.fixture.ts b/libs/playwright-helpers/src/fixtures/auth.fixture.ts index 1de9801747a..bc127c64a8f 100644 --- a/libs/playwright-helpers/src/fixtures/auth.fixture.ts +++ b/libs/playwright-helpers/src/fixtures/auth.fixture.ts @@ -132,9 +132,7 @@ export class AuthFixture { async newSession(email: string, password: string): Promise { const page = await this.page(); - using scene = await Play.scene(new SingleUserSceneTemplate({ email }), { - downAfterAll: true, - }); + const scene = await Play.scene(new SingleUserSceneTemplate({ email })); const mangledEmail = scene.mangle(email); await page.goto("/#/login"); diff --git a/libs/playwright-helpers/src/fixtures/page-extension.ts b/libs/playwright-helpers/src/fixtures/page-extension.ts new file mode 100644 index 00000000000..adc8f510d29 --- /dev/null +++ b/libs/playwright-helpers/src/fixtures/page-extension.ts @@ -0,0 +1,29 @@ +import { Page, TestFixture } from "@playwright/test"; + +export function pageExtension(): TestFixture { + return async ({ page, playId }, use) => { + await page.addInitScript( + ({ p }) => { + const originalFetch = window.fetch; + window.fetch = async function ( + input: string | URL | globalThis.Request, + init?: RequestInit, + ) { + // Build a Request that takes into account both the input and any provided init overrides + const baseRequest = + input instanceof globalThis.Request + ? init + ? new Request(input, init) + : input + : new Request(input, init); + + baseRequest.headers.set("x-play-id", p); + + return originalFetch(baseRequest); + }; + }, + { p: playId }, + ); + await use(page); + }; +} diff --git a/libs/playwright-helpers/src/fixtures/user-state.fixture.ts b/libs/playwright-helpers/src/fixtures/user-state.fixture.ts index a05995182d4..ba9fbed1c8f 100644 --- a/libs/playwright-helpers/src/fixtures/user-state.fixture.ts +++ b/libs/playwright-helpers/src/fixtures/user-state.fixture.ts @@ -1,9 +1,17 @@ -import { Page } from "@playwright/test"; +import { Page, TestFixture } from "@playwright/test"; import { UserKeyDefinition } from "@bitwarden/state"; import { UserId } from "@bitwarden/user-core"; export class UserStateFixture { + static fixtureValue(): TestFixture { + // eslint-disable-next-line no-empty-pattern + return async ({}, use) => { + const userState = new UserStateFixture(); + await use(userState); + }; + } + async get(page: Page, userId: UserId, keyDefinition: UserKeyDefinition): Promise { let json: string | null; switch (keyDefinition.stateDefinition.defaultStorageLocation) { diff --git a/libs/playwright-helpers/src/play.ts b/libs/playwright-helpers/src/play.ts index 675cacdc90e..19a8ba79e3a 100644 --- a/libs/playwright-helpers/src/play.ts +++ b/libs/playwright-helpers/src/play.ts @@ -1,12 +1,7 @@ import { Query } from "./queries/query"; -import { - SceneOptions, - Scene, - SCENE_OPTIONS_DEFAULTS, - seedIdsToTearDown, - seedIdsToWarnAbout, -} from "./scene"; +import { Scene } from "./scene"; import { SceneTemplate } from "./scene-templates/scene-template"; +import { cleanStage, playId } from "./test"; export class Play { /** @@ -21,7 +16,7 @@ export class Play { * import { Play, SingleUserScene } from "@bitwarden/playwright-helpers"; * * test("my test", async ({ page }) => { - * using scene = await Play.scene(new SingleUserScene({ email: " + * const scene = await Play.scene(new SingleUserScene({ email: " * expect(scene.mangle("my-id")).not.toBe("my-id"); * }); * @@ -29,29 +24,42 @@ export class Play { * @param options Options for the scene * @returns */ - static async scene( - template: SceneTemplate, - 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); + static async scene(template: SceneTemplate): Promise> { + const scene = new Scene(); await scene.init(template); - if (!opts.noDown) { - seedIdsToTearDown.add(scene.seedId); - } else { - seedIdsToWarnAbout.add(scene.seedId); - } return scene; } - static async DeleteAllScenes(): Promise { - await Scene.DeleteAllScenes(); + static async clean(): Promise { + await cleanStage(); } static async query(template: Query): Promise { return await template.fetch(); } + + /** + * Utility to mangle strings consistently within a play session. + * The preferred method is to use server-side mangling via Scenes, but this is useful + * for entities that are created as a part of a test, such as user registration. + * + * @param str The string to mangle + * @returns the mangled string + */ + static mangler(str: string): string { + return `${str}_${playId.replaceAll("-", "").slice(0, 8)}`; + } + + /** + * Utility to mangle email addresses consistently within a play session. + * The preferred method is to use server-side mangling via Scenes, but this is useful + * for entities that are created as a part of a test, such as user registration. + * + * @param email The email to mangle + * @returns the mangled email + */ + static mangleEmail(email: string): string { + const [localPart, domain] = email.split("@"); + return `${this.mangler(localPart)}@${domain}`; + } } diff --git a/libs/playwright-helpers/src/scene.ts b/libs/playwright-helpers/src/scene.ts index a6e6a08a01d..9be032ef6e9 100644 --- a/libs/playwright-helpers/src/scene.ts +++ b/libs/playwright-helpers/src/scene.ts @@ -1,13 +1,5 @@ -import { test } from "@playwright/test"; -import { webServerBaseUrl } from "@playwright-config"; - -import { UsingRequired } from "@bitwarden/common/platform/misc/using-required"; - import { SceneTemplate } from "./scene-templates/scene-template"; -// First seed points at the seeder API proxy, second is the seed path of the SeedController -const seedApiUrl = new URL("/seed/seed/", webServerBaseUrl).toString(); - /** * A Scene contains logic to set up and tear down data for a test on the server. * It is created by providing a Scene Template, which contains the arguments the server requires to create the data. @@ -18,13 +10,13 @@ const seedApiUrl = new URL("/seed/seed/", webServerBaseUrl).toString(); * - {@link SceneOptions.noDown}: Useful for setting up data then using codegen to create tests that use the data. Remember to tear down the data manually. * - {@link SceneOptions.downAfterAll}: Useful for expensive setups that you want to share across all tests in a worker or for writing acts. */ -export class Scene implements UsingRequired { +export class Scene { private inited = false; private _template?: SceneTemplate; private mangledMap = new Map(); private _returnValue?: Returns; - constructor(private options: SceneOptions) {} + constructor() {} private get template(): SceneTemplate { if (!this.inited) { @@ -43,56 +35,6 @@ export class Scene implements UsingRequired { return this._returnValue!; } - /** - * Chainable method to set the scene to not be torn down when disposed. - * Note: if you do not tear down the scene, you are responsible for cleaning up any side effects. - * - * @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; - } - - /** Chainable method to set the scene to not be torn down when disposed, but still torn down after all tests complete. - * - * @returns The scene instance for chaining - */ - downAfterAll(): this { - this.options.downAfterAll = true; - return this; - } - - get seedId(): string { - if (!this.inited) { - throw new Error("Scene must be initialized before accessing seedId"); - } - if (!this.template) { - throw new Error("Scene was not properly initialized"); - } - return this.template.currentSeedId; - } - - [Symbol.dispose] = () => { - if (!this.inited || this.options.noDown || this.options.downAfterAll) { - return; - } - - if (!this.template) { - throw new Error("Scene was not properly initialized"); - } - - // Fire off an unawaited promise to delete the side effects of the scene - void this.template.down(); - seedIdsToTearDown.delete(this.seedId); - }; - mangle(id: string): string { if (!this.inited) { throw new Error("Scene must be initialized before mangling ids"); @@ -113,63 +55,4 @@ export class Scene implements UsingRequired { this.mangledMap = new Map(Object.entries(result.mangleMap)); this._returnValue = result.result as unknown as Returns; } - - static async DeleteAllScenes(): Promise { - const response = await fetch(seedApiUrl, { - method: "DELETE", - }); - - if (!response.ok) { - throw new Error(`Failed to delete scenes: ${response.statusText}`); - } - } } - -export type SceneOptions = { - /** - * If true, the scene will not be torn down when disposed. - * Note: if you do not tear down the scene, you are responsible for cleaning up any side effects. - * - * @default false - */ - noDown?: boolean; - /** - * If true, this scene will be torn down after all tests complete, rather than when the scene is disposed. - * - * Note: after all, in this case, means after all tests _for the specific worker_ are complete. Parallelization - * over multiple cores means that these will not be shared between workers, and each worker will tear down its own scenes. - * - * @default false - */ - downAfterAll?: boolean; -}; - -export const SCENE_OPTIONS_DEFAULTS: Readonly = Object.freeze({ - noDown: false, - downAfterAll: false, -}); - -export const seedIdsToTearDown = new Set(); -export 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: { - "Content-Type": "application/json", - }, - body: JSON.stringify(Array.from(seedIdsToTearDown)), - }); - - if (!response.ok) { - throw new Error(`Failed to delete scenes: ${response.statusText}`); - } -}); diff --git a/libs/playwright-helpers/src/test.ts b/libs/playwright-helpers/src/test.ts index 2d431e0a233..2d7c2aaf570 100644 --- a/libs/playwright-helpers/src/test.ts +++ b/libs/playwright-helpers/src/test.ts @@ -1,13 +1,86 @@ import { test as base } from "@playwright/test"; +import { webServerBaseUrl } from "@playwright-config"; import { AuthFixture } from "./fixtures/auth.fixture"; +import { pageExtension } from "./fixtures/page-extension"; import { UserStateFixture } from "./fixtures/user-state.fixture"; interface TestParams { auth: AuthFixture; userState: UserStateFixture; + playId: string; } +export let playId: string; + export const test = base.extend({ auth: AuthFixture.fixtureValue(), + userState: UserStateFixture.fixtureValue(), + // eslint-disable-next-line no-empty-pattern + playId: async ({}, use) => { + await use(playId!); + }, + // TODO: we probably need to extend all means of getting a Page to include the playId fetch + page: pageExtension(), }); + +const originalFetch = global.fetch; + +base.beforeAll(async () => { + playId = crypto.randomUUID(); + Object.freeze(playId); + + // override the global fetch to always include the x-play-id header + // so that any fetch calls made in the test context include the play id + global.fetch = fetchWithPlayId; +}); + +// restore the original fetch after all tests are done +base.afterAll(() => { + global.fetch = originalFetch; + void cleanStage(); +}); + +async function fetchWithPlayId( + input: string | URL | globalThis.Request, + init?: RequestInit, +): Promise { + // Build a Request that takes into account both the input and any provided init overrides + const baseRequest = + input instanceof globalThis.Request + ? init + ? new Request(input, init) + : input + : new Request(input, init); + + baseRequest.headers.set("x-play-id", playId!); + + return originalFetch(baseRequest); +} + +export async function cleanStage(): Promise { + if (!playId) { + throw new Error("Play ID is not set. Cannot clean stage."); + } + + if (process.env.PLAYWRIGHT_SKIP_CLEAN_STAGE === "1") { + // eslint-disable-next-line no-console + console.warn( + "PLAYWRIGHT_SKIP_CLEAN_STAGE is set, run\n", + `curl -X DELETE ${new URL(playId, webServerBaseUrl).toString()}\n`, + ); + return; + } + + const response = await fetch(new URL(`/seed/seed/${playId}/`, webServerBaseUrl).toString(), { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ playId }), + }); + + if (!response.ok) { + throw new Error(`Failed to clean stage: ${response.status} ${response.statusText}`); + } +} diff --git a/playwright.config.ts b/playwright.config.ts index 4897c4af55d..9e2e6737184 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", + reporter: [["html", { open: "never" }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('')`. */