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 d796446e1bd..6c66c04cc3e 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 @@ -60,9 +60,7 @@ const test = base.extend({ }); base.describe("Emergency Access", () => { - test("Account takeover", async ({ grantee, grantor }) => { - test.setTimeout(120_000); - + test.skip("Account takeover", async ({ grantee, grantor }) => { const granteeEmail = grantee.scene.mangle("grantee@bitwarden.com"); // Add a new emergency contact @@ -117,8 +115,6 @@ base.describe("Emergency Access", () => { await grantee.page.getByRole("button", { name: "Save" }).click(); await grantee.page.getByRole("button", { name: "Yes" }).click(); - await grantee.page.pause(); - // TODO: Confirm the new password works by logging out and back in // await new Promise(() => {}); 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 8d8ba8d05cd..fdb471b0ec6 100644 --- a/apps/web/src/app/auth/login/example.play.spec.ts +++ b/apps/web/src/app/auth/login/example.play.spec.ts @@ -1,9 +1,9 @@ import { expect } from "@playwright/test"; -import { Play, SingleUserRecipe, test } from "@bitwarden/playwright-helpers"; +import { Play, SingleUserSceneTemplate, test } from "@bitwarden/playwright-helpers"; test("login with password", async ({ page }) => { - using scene = await Play.scene(new SingleUserRecipe({ email: "test@example.com" })); + using 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(); @@ -16,13 +16,9 @@ test("login with password", async ({ page }) => { .getByRole("textbox", { name: "Master password (required)" }) .fill(scene.mangle("asdfasdfasdf")); await page.getByRole("button", { name: "Log in with master password" }).click(); - await expect(page.getByRole("button", { name: "Add it later" })).toBeVisible(); + await page.getByRole("button", { name: "Add it later" }).click(); - await expect(page.locator("bit-simple-dialog")).toContainText( - "You can't autofill passwords without the browser extension", - ); await page.getByRole("link", { name: "Skip to web app" }).click(); - await expect(page.locator("app-vault")).toContainText("There are no items to list. New item"); }); test("login and save session", async ({ auth }) => { diff --git a/libs/playwright-helpers/src/fixtures/auth.fixture.ts b/libs/playwright-helpers/src/fixtures/auth.fixture.ts index ba0029f4b68..1de9801747a 100644 --- a/libs/playwright-helpers/src/fixtures/auth.fixture.ts +++ b/libs/playwright-helpers/src/fixtures/auth.fixture.ts @@ -1,13 +1,13 @@ import * as fs from "fs"; import * as path from "path"; -import { Browser, Page, test } from "@playwright/test"; +import { Browser, Page, test, TestFixture } 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"; +import { Play, SingleUserScene, SingleUserSceneTemplate } from "@bitwarden/playwright-helpers"; const hostname = new URL(webServerBaseUrl).hostname; const dataDir = process.env.PLAYWRIGHT_DATA_DIR ?? "playwright-data"; @@ -29,14 +29,14 @@ function localFilePath(mangledEmail: string): string { type AuthedUserData = { email: string; password: string; - scene: Scene; + scene: SingleUserScene; }; type AuthenticatedContext = { /** The Playwright page we authenticated */ page: Page; /** The Scene used to authenticate */ - scene: Scene; + scene: SingleUserScene; }; /** @@ -50,6 +50,15 @@ export class AuthFixture { constructor(private readonly browserName: BrowserName) {} + static fixtureValue(): TestFixture { + return async ({ browserName }, use) => { + const auth = new AuthFixture(browserName as BrowserName); + await auth.init(); + await use(auth); + await auth.close(); + }; + } + async init(): Promise { if (!this._browser) { this._browser = await playwright[this.browserName].launch(); @@ -123,7 +132,7 @@ export class AuthFixture { async newSession(email: string, password: string): Promise { const page = await this.page(); - using scene = await Play.scene(new SingleUserRecipe({ email }), { + using scene = await Play.scene(new SingleUserSceneTemplate({ email }), { downAfterAll: true, }); const mangledEmail = scene.mangle(email); diff --git a/libs/playwright-helpers/src/fixtures/user-state.fixture.ts b/libs/playwright-helpers/src/fixtures/user-state.fixture.ts new file mode 100644 index 00000000000..a05995182d4 --- /dev/null +++ b/libs/playwright-helpers/src/fixtures/user-state.fixture.ts @@ -0,0 +1,53 @@ +import { Page } from "@playwright/test"; + +import { UserKeyDefinition } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +export class UserStateFixture { + async get(page: Page, userId: UserId, keyDefinition: UserKeyDefinition): Promise { + let json: string | null; + switch (keyDefinition.stateDefinition.defaultStorageLocation) { + case "disk": + json = await page.evaluate(({ key }) => localStorage.getItem(key), { + key: keyDefinition.buildKey(userId), + }); + break; + case "memory": + json = await page.evaluate(({ key }) => sessionStorage.getItem(key), { + key: keyDefinition.buildKey(userId), + }); + break; + default: + throw new Error( + `Unsupported storage location ${keyDefinition.stateDefinition.defaultStorageLocation}`, + ); + } + return json == null ? null : (JSON.parse(json) as T); + } + + async set( + page: Page, + userId: UserId, + keyDefinition: UserKeyDefinition, + value: T | null, + ): Promise { + switch (keyDefinition.stateDefinition.defaultStorageLocation) { + case "disk": + await page.evaluate(({ key, value }) => localStorage.setItem(key, JSON.stringify(value)), { + key: keyDefinition.buildKey(userId), + value, + }); + return; + case "memory": + await page.evaluate( + ({ key, value }) => sessionStorage.setItem(key, JSON.stringify(value)), + { key: keyDefinition.buildKey(userId), value }, + ); + return; + default: + throw new Error( + `Unsupported storage location ${keyDefinition.stateDefinition.defaultStorageLocation}`, + ); + } + } +} diff --git a/libs/playwright-helpers/src/index.ts b/libs/playwright-helpers/src/index.ts index b1235eb657f..eb7f490ca84 100644 --- a/libs/playwright-helpers/src/index.ts +++ b/libs/playwright-helpers/src/index.ts @@ -1,3 +1,3 @@ export * from "./scene"; -export * from "./recipes"; +export * from "./scene-templates"; export { test } from "./test"; diff --git a/libs/playwright-helpers/src/recipes/emergency-access-invite.recipe.ts b/libs/playwright-helpers/src/recipes/emergency-access-invite.recipe.ts deleted file mode 100644 index b5184f70a3b..00000000000 --- a/libs/playwright-helpers/src/recipes/emergency-access-invite.recipe.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Recipe } from "./recipe"; - -export class EmergencyAccessInviteRecipe extends Recipe<{ - email: string; -}> { - template: string = "EmergencyAccessInviteRecipe"; -} diff --git a/libs/playwright-helpers/src/recipes/index.ts b/libs/playwright-helpers/src/recipes/index.ts deleted file mode 100644 index 55940943dab..00000000000 --- a/libs/playwright-helpers/src/recipes/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Recipes represent server-side seed recipes to create data for tests. - */ - -export * from "./emergency-access-invite.recipe"; -export * from "./organization-with-users.recipe"; -export * from "./single-user.recipe"; diff --git a/libs/playwright-helpers/src/recipes/organization-with-users.recipe.ts b/libs/playwright-helpers/src/recipes/organization-with-users.recipe.ts deleted file mode 100644 index f0108e1f55b..00000000000 --- a/libs/playwright-helpers/src/recipes/organization-with-users.recipe.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Recipe } from "./recipe"; - -export class OrganizationWithUsersRecipe extends Recipe<{ - name: string; - numUsers: number; - domain: string; -}> { - template: string = "OrganizationWithUsersRecipe"; -} diff --git a/libs/playwright-helpers/src/recipes/single-user.recipe.ts b/libs/playwright-helpers/src/recipes/single-user.recipe.ts deleted file mode 100644 index 7fd5a504903..00000000000 --- a/libs/playwright-helpers/src/recipes/single-user.recipe.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Recipe } from "./recipe"; - -export class SingleUserRecipe extends Recipe<{ - email: string; - emailVerified?: boolean; - premium?: boolean; -}> { - template: string = "SingleUserRecipe"; -} diff --git a/libs/playwright-helpers/src/scene-templates/emergency-access-invite.scene.ts b/libs/playwright-helpers/src/scene-templates/emergency-access-invite.scene.ts new file mode 100644 index 00000000000..63e17ada054 --- /dev/null +++ b/libs/playwright-helpers/src/scene-templates/emergency-access-invite.scene.ts @@ -0,0 +1,7 @@ +import { SceneTemplate } from "./scene-template"; + +export class EmergencyAccessInviteQuery extends SceneTemplate<{ + email: string; +}> { + template: string = "EmergencyAccessInviteQuery"; +} diff --git a/libs/playwright-helpers/src/scene-templates/index.ts b/libs/playwright-helpers/src/scene-templates/index.ts new file mode 100644 index 00000000000..1b09b273f86 --- /dev/null +++ b/libs/playwright-helpers/src/scene-templates/index.ts @@ -0,0 +1,6 @@ +/** + * Scene Templates represent server-side seed scenes to create data for tests. + */ + +export * from "./emergency-access-invite.scene"; +export * from "./single-user.scene"; diff --git a/libs/playwright-helpers/src/recipes/recipe.ts b/libs/playwright-helpers/src/scene-templates/scene-template.ts similarity index 50% rename from libs/playwright-helpers/src/recipes/recipe.ts rename to libs/playwright-helpers/src/scene-templates/scene-template.ts index 0ac79f7cbfc..dc33de822c1 100644 --- a/libs/playwright-helpers/src/recipes/recipe.ts +++ b/libs/playwright-helpers/src/scene-templates/scene-template.ts @@ -3,22 +3,25 @@ import { webServerBaseUrl } from "@playwright-config"; // First seed points at the seeder API proxy, second is the seed path of the SeedController const seedApiUrl = new URL("/seed/seed/", webServerBaseUrl).toString(); -export abstract class Recipe { +export abstract class SceneTemplate { abstract template: string; private seedId?: string; get currentSeedId(): string { if (!this.seedId) { - throw new Error("Recipe has not been seeded yet"); + throw new Error("Scene has not been seeded yet"); } return this.seedId; } constructor(private upArgs: TUp) {} - async up(): Promise> { - const result = await recipeUp(this.template, this.upArgs); + async up(): Promise> { + const result = await sceneUp(this.template, this.upArgs); this.seedId = result.seedId; - return result.result; + return { + mangleMap: result.mangleMap, + result: result.result, + }; } async down(): Promise { @@ -26,12 +29,15 @@ export abstract class Recipe { return; } - await recipeDown(this.seedId); + await sceneDown(this.seedId); this.seedId = undefined; } } -async function recipeUp(template: string, args: TUp): Promise { +async function sceneUp( + template: string, + args: TUp, +): Promise> { const response = await fetch(seedApiUrl, { method: "POST", headers: { @@ -44,23 +50,29 @@ async function recipeUp(template: string, args: TUp): Promise { }); if (!response.ok) { - throw new Error(`Failed to seed recipe: ${response.statusText}`); + throw new Error(`Failed to seed scene: ${response.statusText}`); } - return (await response.json()) as SeedResult; + return (await response.json()) as SeederApiResult; } -async function recipeDown(seedId: string): Promise { +async function sceneDown(seedId: string): Promise { const url = new URL(`${seedId}`, seedApiUrl).toString(); const response = await fetch(url, { method: "DELETE", }); if (!response.ok) { - throw new Error(`Failed to delete recipe: ${response.statusText}`); + throw new Error(`Failed to delete scene: ${response.statusText}`); } } -export interface SeedResult { - result: Record; +export interface SeederApiResult { + mangleMap: Record; + result: TReturns; seedId: string; } + +export interface SceneTemplateResult { + mangleMap: Record; + result: TReturns; +} diff --git a/libs/playwright-helpers/src/scene-templates/single-user.scene.ts b/libs/playwright-helpers/src/scene-templates/single-user.scene.ts new file mode 100644 index 00000000000..41ca0691807 --- /dev/null +++ b/libs/playwright-helpers/src/scene-templates/single-user.scene.ts @@ -0,0 +1,20 @@ +import { UserId } from "@bitwarden/user-core"; + +import { Scene } from "../scene"; + +import { SceneTemplate } from "./scene-template"; + +type SceneResult = UserId; + +export type SingleUserScene = Scene; + +export class SingleUserSceneTemplate extends SceneTemplate< + { + email: string; + emailVerified?: boolean; + premium?: boolean; + }, + SceneResult +> { + template: string = "SingleUserScene"; +} diff --git a/libs/playwright-helpers/src/scene.ts b/libs/playwright-helpers/src/scene.ts index bac5d076a23..f8405996fa8 100644 --- a/libs/playwright-helpers/src/scene.ts +++ b/libs/playwright-helpers/src/scene.ts @@ -3,14 +3,14 @@ import { webServerBaseUrl } from "@playwright-config"; import { UsingRequired } from "@bitwarden/common/platform/misc/using-required"; -import { Recipe } from "./recipes/recipe"; +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 running a Recipe, which contains the arguments the server requires to create the data. + * It is created by providing a Scene Template, which contains the arguments the server requires to create the data. * * Scenes are `Disposable`, meaning they must be used with the `using` keyword and will be automatically torn down when disposed. * Options exist to modify this behavior. @@ -18,21 +18,29 @@ 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 implements UsingRequired { private inited = false; - private _recipe?: Recipe; - private mangledMap = new Map(); + private _template?: SceneTemplate; + private mangledMap = new Map(); + private _returnValue?: Returns; constructor(private options: SceneOptions) {} - private get recipe(): Recipe { + private get template(): SceneTemplate { if (!this.inited) { - throw new Error("Scene must be initialized before accessing recipe"); + throw new Error("Scene must be initialized before accessing template"); } - if (!this._recipe) { + if (!this._template) { throw new Error("Scene was not properly initialized"); } - return this._recipe; + return this._template; + } + + get returnValue(): Returns { + if (!this.inited) { + throw new Error("Scene must be initialized before accessing returnValue"); + } + return this._returnValue!; } /** @@ -65,10 +73,10 @@ export class Scene implements UsingRequired { if (!this.inited) { throw new Error("Scene must be initialized before accessing seedId"); } - if (!this.recipe) { + if (!this.template) { throw new Error("Scene was not properly initialized"); } - return this.recipe.currentSeedId; + return this.template.currentSeedId; } [Symbol.dispose] = () => { @@ -76,12 +84,12 @@ export class Scene implements UsingRequired { return; } - if (!this.recipe) { + 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.recipe.down(); + void this.template.down(); seedIdsToTearDown.delete(this.seedId); }; @@ -93,16 +101,17 @@ export class Scene implements UsingRequired { return this.mangledMap.get(id) ?? id; } - async init, TUp>(recipe: T): Promise { + async init, TUp>(template: T): Promise { if (this.inited) { throw new Error("Scene has already been initialized"); } - this._recipe = recipe; + this._template = template; this.inited = true; - const mangleMap = await recipe.up(); + const result = await template.up(); - this.mangledMap = new Map(Object.entries(mangleMap)); + this.mangledMap = new Map(Object.entries(result.mangleMap)); + this._returnValue = result.result as unknown as Returns; } } @@ -132,35 +141,35 @@ const SCENE_OPTIONS_DEFAULTS: Readonly = Object.freeze({ export class Play { /** - * Runs server-side recipes to create a test scene. Automatically destroys the scene when disposed. + * Runs server-side code to create a test scene and automatically destroys the scene when disposed. * * Scenes also expose a `mangle` method that can be used to mangle magic string in the same way the server reports them - * back to avoid collisions. For example, if a recipe creates a user with the email `test@example.com`, you can call + * back to avoid collisions. For example, if a scene creates a user with the email `test@example.com`, you can call * `scene.mangle("test@example.com")` to get the actual email address of the user created in the scene. * * Example usage: * ```ts - * import { Play, SingleUserRecipe } from "@bitwarden/playwright-helpers"; + * import { Play, SingleUserScene } from "@bitwarden/playwright-helpers"; * * test("my test", async ({ page }) => { - * using scene = await Play.scene(new SingleUserRecipe({ email: " + * using scene = await Play.scene(new SingleUserScene({ email: " * expect(scene.mangle("my-id")).not.toBe("my-id"); * }); * - * @param recipe The recipe to run to create the scene + * @param template The template to run to create the scene * @param options Options for the scene * @returns */ - static async scene, TUp>( - recipe: T, + static async scene( + template: SceneTemplate, options: SceneOptions = {}, - ): Promise { + ): 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); + const scene = new Scene(opts); + await scene.init(template); if (!opts.noDown) { seedIdsToTearDown.add(scene.seedId); } else { @@ -175,7 +184,7 @@ export class Play { }); if (!response.ok) { - throw new Error(`Failed to delete recipes: ${response.statusText}`); + throw new Error(`Failed to delete scenes: ${response.statusText}`); } } } @@ -201,6 +210,6 @@ test.afterAll(async () => { }); if (!response.ok) { - throw new Error(`Failed to delete recipes: ${response.statusText}`); + 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 d721fd40475..2d431e0a233 100644 --- a/libs/playwright-helpers/src/test.ts +++ b/libs/playwright-helpers/src/test.ts @@ -1,18 +1,13 @@ import { test as base } from "@playwright/test"; import { AuthFixture } from "./fixtures/auth.fixture"; +import { UserStateFixture } from "./fixtures/user-state.fixture"; interface TestParams { auth: AuthFixture; + userState: UserStateFixture; } export const test = base.extend({ - auth: async ({ browserName }, use) => { - const authedPage = new AuthFixture(browserName); - await authedPage.init(); - - await use(authedPage); - - await authedPage.close(); - }, + auth: AuthFixture.fixtureValue(), });