1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 10:43:47 +00:00

Rename recipes to scenes and scene templates

This commit is contained in:
Matt Gibson
2025-10-29 15:04:14 -07:00
parent 3059053e35
commit bc9e40dbd0
15 changed files with 171 additions and 100 deletions

View File

@@ -60,9 +60,7 @@ const test = base.extend<MyFixtures>({
});
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(() => {});

View File

@@ -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 }) => {

View File

@@ -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<AuthFixture, { browserName: BrowserName }> {
return async ({ browserName }, use) => {
const auth = new AuthFixture(browserName as BrowserName);
await auth.init();
await use(auth);
await auth.close();
};
}
async init(): Promise<void> {
if (!this._browser) {
this._browser = await playwright[this.browserName].launch();
@@ -123,7 +132,7 @@ export class AuthFixture {
async newSession(email: string, password: string): Promise<AuthenticatedContext> {
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);

View File

@@ -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<T>(page: Page, userId: UserId, keyDefinition: UserKeyDefinition<T>): Promise<T | null> {
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<T>(
page: Page,
userId: UserId,
keyDefinition: UserKeyDefinition<T>,
value: T | null,
): Promise<void> {
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}`,
);
}
}
}

View File

@@ -1,3 +1,3 @@
export * from "./scene";
export * from "./recipes";
export * from "./scene-templates";
export { test } from "./test";

View File

@@ -1,7 +0,0 @@
import { Recipe } from "./recipe";
export class EmergencyAccessInviteRecipe extends Recipe<{
email: string;
}> {
template: string = "EmergencyAccessInviteRecipe";
}

View File

@@ -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";

View File

@@ -1,9 +0,0 @@
import { Recipe } from "./recipe";
export class OrganizationWithUsersRecipe extends Recipe<{
name: string;
numUsers: number;
domain: string;
}> {
template: string = "OrganizationWithUsersRecipe";
}

View File

@@ -1,9 +0,0 @@
import { Recipe } from "./recipe";
export class SingleUserRecipe extends Recipe<{
email: string;
emailVerified?: boolean;
premium?: boolean;
}> {
template: string = "SingleUserRecipe";
}

View File

@@ -0,0 +1,7 @@
import { SceneTemplate } from "./scene-template";
export class EmergencyAccessInviteQuery extends SceneTemplate<{
email: string;
}> {
template: string = "EmergencyAccessInviteQuery";
}

View File

@@ -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";

View File

@@ -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<TUp> {
export abstract class SceneTemplate<TUp, TReturns = void> {
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<Record<string, string>> {
const result = await recipeUp(this.template, this.upArgs);
async up(): Promise<SceneTemplateResult<TReturns>> {
const result = await sceneUp<TUp, TReturns>(this.template, this.upArgs);
this.seedId = result.seedId;
return result.result;
return {
mangleMap: result.mangleMap,
result: result.result,
};
}
async down(): Promise<void> {
@@ -26,12 +29,15 @@ export abstract class Recipe<TUp> {
return;
}
await recipeDown(this.seedId);
await sceneDown(this.seedId);
this.seedId = undefined;
}
}
async function recipeUp<TUp>(template: string, args: TUp): Promise<SeedResult> {
async function sceneUp<TUp, TReturns>(
template: string,
args: TUp,
): Promise<SeederApiResult<TReturns>> {
const response = await fetch(seedApiUrl, {
method: "POST",
headers: {
@@ -44,23 +50,29 @@ async function recipeUp<TUp>(template: string, args: TUp): Promise<SeedResult> {
});
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<TReturns>;
}
async function recipeDown(seedId: string): Promise<void> {
async function sceneDown(seedId: string): Promise<void> {
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<string, string>;
export interface SeederApiResult<TReturns> {
mangleMap: Record<string, string | null>;
result: TReturns;
seedId: string;
}
export interface SceneTemplateResult<TReturns> {
mangleMap: Record<string, string | null>;
result: TReturns;
}

View File

@@ -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<SceneResult>;
export class SingleUserSceneTemplate extends SceneTemplate<
{
email: string;
emailVerified?: boolean;
premium?: boolean;
},
SceneResult
> {
template: string = "SingleUserScene";
}

View File

@@ -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<Returns = void> implements UsingRequired {
private inited = false;
private _recipe?: Recipe<unknown>;
private mangledMap = new Map<string, string>();
private _template?: SceneTemplate<unknown, Returns>;
private mangledMap = new Map<string, string | null>();
private _returnValue?: Returns;
constructor(private options: SceneOptions) {}
private get recipe(): Recipe<unknown> {
private get template(): SceneTemplate<unknown, Returns> {
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<T extends Recipe<TUp>, TUp>(recipe: T): Promise<void> {
async init<T extends SceneTemplate<TUp, Returns>, TUp>(template: T): Promise<void> {
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<SceneOptions> = 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<T extends Recipe<TUp>, TUp>(
recipe: T,
static async scene<TUp, TResult>(
template: SceneTemplate<TUp, TResult>,
options: SceneOptions = {},
): Promise<Scene> {
): Promise<Scene<TResult>> {
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<TResult>(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}`);
}
});

View File

@@ -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<TestParams>({
auth: async ({ browserName }, use) => {
const authedPage = new AuthFixture(browserName);
await authedPage.init();
await use(authedPage);
await authedPage.close();
},
auth: AuthFixture.fixtureValue(),
});