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:
@@ -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(() => {});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
53
libs/playwright-helpers/src/fixtures/user-state.fixture.ts
Normal file
53
libs/playwright-helpers/src/fixtures/user-state.fixture.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./scene";
|
||||
export * from "./recipes";
|
||||
export * from "./scene-templates";
|
||||
export { test } from "./test";
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Recipe } from "./recipe";
|
||||
|
||||
export class EmergencyAccessInviteRecipe extends Recipe<{
|
||||
email: string;
|
||||
}> {
|
||||
template: string = "EmergencyAccessInviteRecipe";
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Recipe } from "./recipe";
|
||||
|
||||
export class OrganizationWithUsersRecipe extends Recipe<{
|
||||
name: string;
|
||||
numUsers: number;
|
||||
domain: string;
|
||||
}> {
|
||||
template: string = "OrganizationWithUsersRecipe";
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Recipe } from "./recipe";
|
||||
|
||||
export class SingleUserRecipe extends Recipe<{
|
||||
email: string;
|
||||
emailVerified?: boolean;
|
||||
premium?: boolean;
|
||||
}> {
|
||||
template: string = "SingleUserRecipe";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SceneTemplate } from "./scene-template";
|
||||
|
||||
export class EmergencyAccessInviteQuery extends SceneTemplate<{
|
||||
email: string;
|
||||
}> {
|
||||
template: string = "EmergencyAccessInviteQuery";
|
||||
}
|
||||
6
libs/playwright-helpers/src/scene-templates/index.ts
Normal file
6
libs/playwright-helpers/src/scene-templates/index.ts
Normal 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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user