mirror of
https://github.com/bitwarden/browser
synced 2026-01-28 15:23:53 +00:00
Use playId to track all creations within a worker.
This simplifies scenes and allows tracking of entities created during tests as well as those set up for tests. However, we need to override the browser's `fetch` method to accomplish this. I have added a page extension that does this, but it is possible to create pages from browser contexts, contexts from browsers and even browsers from browser names. We simply need to expect imperfect coverage on this header.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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<UserId>;
|
||||
};
|
||||
|
||||
const test = base.extend<MyFixtures>({
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -132,9 +132,7 @@ export class AuthFixture {
|
||||
|
||||
async newSession(email: string, password: string): Promise<AuthenticatedContext> {
|
||||
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");
|
||||
|
||||
|
||||
29
libs/playwright-helpers/src/fixtures/page-extension.ts
Normal file
29
libs/playwright-helpers/src/fixtures/page-extension.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Page, TestFixture } from "@playwright/test";
|
||||
|
||||
export function pageExtension(): TestFixture<Page, { page: Page; playId: string }> {
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -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<UserStateFixture, any> {
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
return async ({}, use) => {
|
||||
const userState = new UserStateFixture();
|
||||
await use(userState);
|
||||
};
|
||||
}
|
||||
|
||||
async get<T>(page: Page, userId: UserId, keyDefinition: UserKeyDefinition<T>): Promise<T | null> {
|
||||
let json: string | null;
|
||||
switch (keyDefinition.stateDefinition.defaultStorageLocation) {
|
||||
|
||||
@@ -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<TUp, TResult>(
|
||||
template: SceneTemplate<TUp, TResult>,
|
||||
options: SceneOptions = {},
|
||||
): 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<TResult>(opts);
|
||||
static async scene<TUp, TResult>(template: SceneTemplate<TUp, TResult>): Promise<Scene<TResult>> {
|
||||
const scene = new Scene<TResult>();
|
||||
await scene.init(template);
|
||||
if (!opts.noDown) {
|
||||
seedIdsToTearDown.add(scene.seedId);
|
||||
} else {
|
||||
seedIdsToWarnAbout.add(scene.seedId);
|
||||
}
|
||||
return scene;
|
||||
}
|
||||
|
||||
static async DeleteAllScenes(): Promise<void> {
|
||||
await Scene.DeleteAllScenes();
|
||||
static async clean(): Promise<void> {
|
||||
await cleanStage();
|
||||
}
|
||||
|
||||
static async query<TUp, TReturns>(template: Query<TUp, TReturns>): Promise<TReturns> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Returns = void> implements UsingRequired {
|
||||
export class Scene<Returns = void> {
|
||||
private inited = false;
|
||||
private _template?: SceneTemplate<unknown, Returns>;
|
||||
private mangledMap = new Map<string, string | null>();
|
||||
private _returnValue?: Returns;
|
||||
|
||||
constructor(private options: SceneOptions) {}
|
||||
constructor() {}
|
||||
|
||||
private get template(): SceneTemplate<unknown, Returns> {
|
||||
if (!this.inited) {
|
||||
@@ -43,56 +35,6 @@ export class Scene<Returns = void> 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<Returns = void> implements UsingRequired {
|
||||
this.mangledMap = new Map(Object.entries(result.mangleMap));
|
||||
this._returnValue = result.result as unknown as Returns;
|
||||
}
|
||||
|
||||
static async DeleteAllScenes(): Promise<void> {
|
||||
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<SceneOptions> = Object.freeze({
|
||||
noDown: false,
|
||||
downAfterAll: false,
|
||||
});
|
||||
|
||||
export const seedIdsToTearDown = new Set<string>();
|
||||
export const seedIdsToWarnAbout = new Set<string>();
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<TestParams>({
|
||||
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<Response> {
|
||||
// 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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('')`. */
|
||||
|
||||
Reference in New Issue
Block a user