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('')`. */