diff --git a/.gitignore b/.gitignore index 6b792b25eef..34f047f17a6 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,5 @@ apps/**/config/local.json /blob-report/ /playwright/.cache/ /playwright/.auth/ +# Stores local state data for playwright while it runs +/playwright-data/ diff --git a/libs/playwright-scenes/README.md b/libs/playwright-helpers/README.md similarity index 100% rename from libs/playwright-scenes/README.md rename to libs/playwright-helpers/README.md diff --git a/libs/playwright-scenes/eslint.config.mjs b/libs/playwright-helpers/eslint.config.mjs similarity index 100% rename from libs/playwright-scenes/eslint.config.mjs rename to libs/playwright-helpers/eslint.config.mjs diff --git a/libs/playwright-scenes/jest.config.js b/libs/playwright-helpers/jest.config.js similarity index 83% rename from libs/playwright-scenes/jest.config.js rename to libs/playwright-helpers/jest.config.js index a2d98ee4db1..5211e018472 100644 --- a/libs/playwright-scenes/jest.config.js +++ b/libs/playwright-helpers/jest.config.js @@ -1,4 +1,4 @@ -const sharedConfig = require("../../libs/shared/jest.config.angular"); +const sharedConfig = require("../shared/jest.config.angular"); module.exports = { ...sharedConfig, diff --git a/libs/playwright-scenes/package.json b/libs/playwright-helpers/package.json similarity index 100% rename from libs/playwright-scenes/package.json rename to libs/playwright-helpers/package.json diff --git a/libs/playwright-scenes/project.json b/libs/playwright-helpers/project.json similarity index 100% rename from libs/playwright-scenes/project.json rename to libs/playwright-helpers/project.json diff --git a/libs/playwright-helpers/src/acts/authenticate-as.ts b/libs/playwright-helpers/src/acts/authenticate-as.ts new file mode 100644 index 00000000000..df1599290fa --- /dev/null +++ b/libs/playwright-helpers/src/acts/authenticate-as.ts @@ -0,0 +1,124 @@ +import * as fs from "fs"; + +import { Page } from "@playwright/test"; +import { webServerBaseUrl } from "@playwright-config"; + +import { Play, Scene } from "@bitwarden/playwright-helpers"; + +const hostname = new URL(webServerBaseUrl).hostname; +const dataDir = process.env.PLAYWRIGHT_DATA_DIR ?? "playwright-data"; +// Ensure data directory exists +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +type AuthedUserData = { + email: string; + password: string; + scene: Scene; +}; + +type AuthenticatedContext = { + page: Page; + scene: Scene; +}; + +/** + * A map of already authenticated emails to their scenes. + */ +const AuthenticatedEmails = new Map(); + +function dataFilePath(email: string): string { + return `${dataDir}/auth-${email}.json`; +} +function sessionFilePath(email: string): string { + return `${dataDir}/session-${email}.json`; +} + +/** + * Helper to ensure a user exists and is authenticated in playwright tests. + */ +export async function authenticateAs( + page: Page, + email: string, + password: string, +): Promise { + // Return existing scene if already authenticated + if (AuthenticatedEmails.has(email)) { + if (AuthenticatedEmails.get(email)!.password !== password) { + throw new Error( + `Email ${email} is already authenticated with a different password (${AuthenticatedEmails.get(email)!.password})`, + ); + } + + await page.context().storageState({ path: dataFilePath(email) }); + await loadSession(page, email); + return { + page, + scene: AuthenticatedEmails.get(email)!.scene, + }; + } + + return newAuthenticateAs(email, password); +} + +function newAuthenticateAs(email: string, password: string): Promise { + using scene = await Play.scene(new SingleUserRecipe({ email, password }), { + downAfterAll: true, + }); + await page.goto("/#/login"); + + await page.locator("#login_input_email").fill(scene.mangle("test@example.com")); + await page.locator("#login_button_continue").click(); + + await page.locator("#login_input_password").fill("asdfasdfasdf"); + await page.locator("#login_button_submit").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"); + + // Store the scene for future use + AuthenticatedEmails.set(email, { email, password, scene }); + + // Save storage state to avoid logging in again + await page.context().storageState({ path: dataFilePath(email) }); + await saveSession(page, email); + + return { page, scene }; +} + +async function saveSession(page: Page, email: string): Promise { + // Get session storage and store as env variable + const sessionStorage = await page.evaluate(() => JSON.stringify(sessionStorage)); + fs.writeFileSync("playwright/.auth/session.json", sessionStorage, "utf-8"); + + // Set session storage in a new context + const sessionStorage = JSON.parse(fs.readFileSync("playwright/.auth/session.json", "utf-8")); + await context.addInitScript((storage) => { + if (window.location.hostname === "example.com") { + for (const [key, value] of Object.entries(storage)) { + window.sessionStorage.setItem(key, value); + } + } + }, sessionStorage); +} + +async function loadSession(page: Page, email: string): Promise { + if (!fs.existsSync(sessionFilePath(email))) { + throw new Error("No session file found"); + } + // Set session storage in a new context + const sessionStorage = JSON.parse(fs.readFileSync(sessionFilePath(email), "utf-8")); + await context.addInitScript((storage) => { + if (window.location.hostname === hostname) { + for (const [key, value] of Object.entries(storage)) { + window.sessionStorage.setItem(key, value); + } + } + }, sessionStorage); +} diff --git a/libs/playwright-helpers/src/acts/index.ts b/libs/playwright-helpers/src/acts/index.ts new file mode 100644 index 00000000000..095c4ff3660 --- /dev/null +++ b/libs/playwright-helpers/src/acts/index.ts @@ -0,0 +1,8 @@ +/** + * Acts are Playwright setups that are intended to allow reuse across different tests. + * They should have logic to ensure they are run only once per unique input. + * They should also handle teardown of any resources they create. + * Finally, they should return any data needed to interact with the setup. + */ + +export * from "./authenticate-as"; diff --git a/libs/playwright-scenes/src/index.ts b/libs/playwright-helpers/src/index.ts similarity index 68% rename from libs/playwright-scenes/src/index.ts rename to libs/playwright-helpers/src/index.ts index b29813125d7..204e59721f2 100644 --- a/libs/playwright-scenes/src/index.ts +++ b/libs/playwright-helpers/src/index.ts @@ -1,2 +1,3 @@ export * from "./scene"; export * from "./recipes"; +export * from "./acts"; diff --git a/libs/playwright-scenes/src/recipes/index.ts b/libs/playwright-helpers/src/recipes/index.ts similarity index 52% rename from libs/playwright-scenes/src/recipes/index.ts rename to libs/playwright-helpers/src/recipes/index.ts index 616700ccbcb..d5034ed297a 100644 --- a/libs/playwright-scenes/src/recipes/index.ts +++ b/libs/playwright-helpers/src/recipes/index.ts @@ -1,2 +1,6 @@ +/** + * Recipes represent server-side seed recipes to create data for tests. + */ + export * from "./organization-with-users.recipe"; export * from "./single-user.recipe"; diff --git a/libs/playwright-scenes/src/recipes/organization-with-users.recipe.ts b/libs/playwright-helpers/src/recipes/organization-with-users.recipe.ts similarity index 100% rename from libs/playwright-scenes/src/recipes/organization-with-users.recipe.ts rename to libs/playwright-helpers/src/recipes/organization-with-users.recipe.ts diff --git a/libs/playwright-scenes/src/recipes/recipe.ts b/libs/playwright-helpers/src/recipes/recipe.ts similarity index 100% rename from libs/playwright-scenes/src/recipes/recipe.ts rename to libs/playwright-helpers/src/recipes/recipe.ts diff --git a/libs/playwright-scenes/src/recipes/single-user.recipe.ts b/libs/playwright-helpers/src/recipes/single-user.recipe.ts similarity index 100% rename from libs/playwright-scenes/src/recipes/single-user.recipe.ts rename to libs/playwright-helpers/src/recipes/single-user.recipe.ts diff --git a/libs/playwright-scenes/src/scene.ts b/libs/playwright-helpers/src/scene.ts similarity index 85% rename from libs/playwright-scenes/src/scene.ts rename to libs/playwright-helpers/src/scene.ts index 750c87a912d..89eb7bc2c93 100644 --- a/libs/playwright-scenes/src/scene.ts +++ b/libs/playwright-helpers/src/scene.ts @@ -8,7 +8,17 @@ import { Recipe } from "./recipes/recipe"; // First seed points at the seeder API proxy, second is the seed path of the SeedController const seedApiUrl = new URL("/seed/seed/", webServerBaseUrl).toString(); -class Scene implements UsingRequired { +/** + * 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. + * + * 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. + * + * - {@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 { private inited = false; private _recipe?: Recipe; private mangledMap = new Map(); @@ -125,7 +135,7 @@ export class Play { * * Example usage: * ```ts - * import { Play, SingleUserRecipe } from "@bitwarden/playwright-scenes"; + * import { Play, SingleUserRecipe } from "@bitwarden/playwright-helpers"; * * test("my test", async ({ page }) => { * using scene = await Play.scene(new SingleUserRecipe({ email: " @@ -140,9 +150,10 @@ export class Play { recipe: T, options: SceneOptions = {}, ): Promise { - const scene = new Scene({ SCENE_OPTIONS_DEFAULTS, ...options }); + const opts = { ...SCENE_OPTIONS_DEFAULTS, ...options }; + const scene = new Scene(opts); await scene.init(recipe); - if (!scene.options.noDown) { + if (!opts.noDown) { seedIdsToTearDown.add(scene.seedId); } return scene; diff --git a/libs/playwright-scenes/tsconfig.eslint.json b/libs/playwright-helpers/tsconfig.eslint.json similarity index 100% rename from libs/playwright-scenes/tsconfig.eslint.json rename to libs/playwright-helpers/tsconfig.eslint.json diff --git a/libs/playwright-scenes/tsconfig.json b/libs/playwright-helpers/tsconfig.json similarity index 100% rename from libs/playwright-scenes/tsconfig.json rename to libs/playwright-helpers/tsconfig.json diff --git a/libs/playwright-scenes/tsconfig.lib.json b/libs/playwright-helpers/tsconfig.lib.json similarity index 100% rename from libs/playwright-scenes/tsconfig.lib.json rename to libs/playwright-helpers/tsconfig.lib.json diff --git a/libs/playwright-scenes/tsconfig.spec.json b/libs/playwright-helpers/tsconfig.spec.json similarity index 100% rename from libs/playwright-scenes/tsconfig.spec.json rename to libs/playwright-helpers/tsconfig.spec.json diff --git a/tsconfig.base.json b/tsconfig.base.json index b62085d69da..6cf0d7671a9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -65,7 +65,8 @@ "@bitwarden/vault-export-core": ["./libs/tools/export/vault-export/vault-export-core/src"], "@bitwarden/vault-export-ui": ["./libs/tools/export/vault-export/vault-export-ui/src"], "@bitwarden/web-vault/*": ["./apps/web/src/*"], - "@playwright-config": ["./playwright.config.ts"] + "@playwright-config": ["./playwright.config.ts"], + "@playwright-data": ["./playwright-data"] }, "plugins": [ {