From 5c19a7c9dd256d703f2a2327d82478b7697fa965 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Tue, 7 Oct 2025 17:00:49 -0700 Subject: [PATCH] Create playwright scenes framework This is a client-side implementation of the db recipes seeding framework for bitwarden server --- .github/CODEOWNERS | 1 + jest.config.js | 1 + libs/playwright-scenes/README.md | 5 ++ libs/playwright-scenes/eslint.config.mjs | 3 + libs/playwright-scenes/jest.config.js | 13 ++++ libs/playwright-scenes/package.json | 11 ++++ libs/playwright-scenes/project.json | 40 +++++++++++ libs/playwright-scenes/src/index.ts | 2 + libs/playwright-scenes/src/recipes/index.ts | 1 + .../recipes/organization-with-users.recipe.ts | 9 +++ libs/playwright-scenes/src/recipes/recipe.ts | 52 +++++++++++++++ libs/playwright-scenes/src/scene.ts | 66 +++++++++++++++++++ libs/playwright-scenes/tsconfig.eslint.json | 6 ++ libs/playwright-scenes/tsconfig.json | 13 ++++ libs/playwright-scenes/tsconfig.lib.json | 10 +++ libs/playwright-scenes/tsconfig.spec.json | 10 +++ package-lock.json | 9 +++ tsconfig.base.json | 1 + 18 files changed, 253 insertions(+) create mode 100644 libs/playwright-scenes/README.md create mode 100644 libs/playwright-scenes/eslint.config.mjs create mode 100644 libs/playwright-scenes/jest.config.js create mode 100644 libs/playwright-scenes/package.json create mode 100644 libs/playwright-scenes/project.json create mode 100644 libs/playwright-scenes/src/index.ts create mode 100644 libs/playwright-scenes/src/recipes/index.ts create mode 100644 libs/playwright-scenes/src/recipes/organization-with-users.recipe.ts create mode 100644 libs/playwright-scenes/src/recipes/recipe.ts create mode 100644 libs/playwright-scenes/src/scene.ts create mode 100644 libs/playwright-scenes/tsconfig.eslint.json create mode 100644 libs/playwright-scenes/tsconfig.json create mode 100644 libs/playwright-scenes/tsconfig.lib.json create mode 100644 libs/playwright-scenes/tsconfig.spec.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 22c14f6b433..d7a2dc96d10 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -219,3 +219,4 @@ apps/web/src/locales/en/messages.json **/jest.config.js @bitwarden/team-platform-dev **/project.jsons @bitwarden/team-platform-dev libs/pricing @bitwarden/team-billing-dev +libs/playwright-scenes @bitwarden/team-architecture diff --git a/jest.config.js b/jest.config.js index e5aeb536172..be0e77724b1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -59,6 +59,7 @@ module.exports = { "/libs/tools/send/send-ui/jest.config.js", "/libs/user-core/jest.config.js", "/libs/vault/jest.config.js", + "/libs/playwright-scenes/jest.config.js", ], // Workaround for a memory leak that crashes tests in CI: diff --git a/libs/playwright-scenes/README.md b/libs/playwright-scenes/README.md new file mode 100644 index 00000000000..5c2e4fcf432 --- /dev/null +++ b/libs/playwright-scenes/README.md @@ -0,0 +1,5 @@ +# playwright-scenes + +Owned by: architecture + +Framework for writing end-to-end playwright tests diff --git a/libs/playwright-scenes/eslint.config.mjs b/libs/playwright-scenes/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/playwright-scenes/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/playwright-scenes/jest.config.js b/libs/playwright-scenes/jest.config.js new file mode 100644 index 00000000000..f758fcc3877 --- /dev/null +++ b/libs/playwright-scenes/jest.config.js @@ -0,0 +1,13 @@ +const sharedConfig = require("../../libs/shared/jest.config.angular"); + +module.exports = { + ...sharedConfig, + displayName: "playwright-scenes", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/playwright-scenes", +}; diff --git a/libs/playwright-scenes/package.json b/libs/playwright-scenes/package.json new file mode 100644 index 00000000000..f449ae09065 --- /dev/null +++ b/libs/playwright-scenes/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/playwright-scenes", + "version": "0.0.1", + "description": "Framework for writing end-to-end playwright tests", + "private": true, + "type": "commonjs", + "main": "index.js", + "types": "index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/playwright-scenes/project.json b/libs/playwright-scenes/project.json new file mode 100644 index 00000000000..0fb3fb90485 --- /dev/null +++ b/libs/playwright-scenes/project.json @@ -0,0 +1,40 @@ +{ + "name": "playwright-scenes", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/playwright-scenes/src", + "projectType": "library", + "tags": ["!dependsOn:common"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/playwright-scenes", + "main": "libs/playwright-scenes/src/index.ts", + "tsConfig": "libs/playwright-scenes/tsconfig.lib.json", + "assets": ["libs/playwright-scenes/*.md"], + "rootDir": "libs/playwright-scenes/src" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/playwright-scenes/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/playwright-scenes/jest.config.js" + } + }, + "e2e": { + "executor": "nx:run-commands", + "options": { + "command": "npx playwright test" + } + } + } +} diff --git a/libs/playwright-scenes/src/index.ts b/libs/playwright-scenes/src/index.ts new file mode 100644 index 00000000000..b29813125d7 --- /dev/null +++ b/libs/playwright-scenes/src/index.ts @@ -0,0 +1,2 @@ +export * from "./scene"; +export * from "./recipes"; diff --git a/libs/playwright-scenes/src/recipes/index.ts b/libs/playwright-scenes/src/recipes/index.ts new file mode 100644 index 00000000000..6f270fd2f1a --- /dev/null +++ b/libs/playwright-scenes/src/recipes/index.ts @@ -0,0 +1 @@ +export * from "./organization-with-users.recipe"; diff --git a/libs/playwright-scenes/src/recipes/organization-with-users.recipe.ts b/libs/playwright-scenes/src/recipes/organization-with-users.recipe.ts new file mode 100644 index 00000000000..f0108e1f55b --- /dev/null +++ b/libs/playwright-scenes/src/recipes/organization-with-users.recipe.ts @@ -0,0 +1,9 @@ +import { Recipe } from "./recipe"; + +export class OrganizationWithUsersRecipe extends Recipe<{ + name: string; + numUsers: number; + domain: string; +}> { + template: string = "OrganizationWithUsersRecipe"; +} diff --git a/libs/playwright-scenes/src/recipes/recipe.ts b/libs/playwright-scenes/src/recipes/recipe.ts new file mode 100644 index 00000000000..c2e48fd858a --- /dev/null +++ b/libs/playwright-scenes/src/recipes/recipe.ts @@ -0,0 +1,52 @@ +import * as playwrightConfig from "../../../../playwright.config"; + +const { webServer } = playwrightConfig.default as { webServer: { url: string } }; + +export abstract class Recipe { + abstract template: string; + private seedId?: string; + + constructor(private upArgs: TUp) {} + async up(): Promise> { + const response = await fetch(`${webServer.url}/api/seed`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + template: this.template, + arguments: this.upArgs, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to seed recipe: ${response.statusText}`); + } + + const result = JSON.parse(await response.json()) as SeedResult; + this.seedId = result.seedId; + return result.mangleMap; + } + + async down(): Promise { + const response = await fetch(`${webServer.url}/api/delete`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + template: this.template, + seedId: this.seedId, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to delete recipe: ${response.statusText}`); + } + } +} + +export interface SeedResult { + mangleMap: Record; + seedId: string; +} diff --git a/libs/playwright-scenes/src/scene.ts b/libs/playwright-scenes/src/scene.ts new file mode 100644 index 00000000000..93e1a7f99c0 --- /dev/null +++ b/libs/playwright-scenes/src/scene.ts @@ -0,0 +1,66 @@ +import { expect } from "@playwright/test"; + +import { UsingRequired } from "@bitwarden/common/platform/misc/using-required"; + +import { OrganizationWithUsersRecipe } from "./recipes/organization-with-users.recipe"; +import { Recipe } from "./recipes/recipe"; + +export class Scene implements UsingRequired { + private inited = false; + private recipe?: Recipe; + private mangledMap = new Map(); + + [Symbol.dispose] = () => { + if (!this.inited) { + return; + } + + if (!this.recipe) { + 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(); + }; + + mangle(id: string): string { + if (!this.inited) { + throw new Error("Scene must be initialized before mangling ids"); + } + + return this.mangledMap.get(id) ?? id; + } + + async init, TUp>(recipe: T): Promise { + if (this.inited) { + throw new Error("Scene has already been initialized"); + } + this.recipe = recipe; + this.inited = true; + + const response = await recipe.up(); + + this.mangledMap = new Map(Object.entries(response.mangleMap)); + } +} + +export class Play { + static async scene, TUp>(recipe: T): Promise { + const scene = new Scene(); + await scene.init(recipe); + return scene; + } +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- example usage of the framework +async function test() { + // example usage + const recipe = new OrganizationWithUsersRecipe({ + name: "My Org", + numUsers: 3, + domain: "example.com", + }); + using scene = await Play.scene(recipe); + + expect(scene.mangle("my-id")).toBe("my-id"); +} diff --git a/libs/playwright-scenes/tsconfig.eslint.json b/libs/playwright-scenes/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/playwright-scenes/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/playwright-scenes/tsconfig.json b/libs/playwright-scenes/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/playwright-scenes/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/playwright-scenes/tsconfig.lib.json b/libs/playwright-scenes/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/playwright-scenes/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/playwright-scenes/tsconfig.spec.json b/libs/playwright-scenes/tsconfig.spec.json new file mode 100644 index 00000000000..512ff7465c0 --- /dev/null +++ b/libs/playwright-scenes/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/package-lock.json b/package-lock.json index 11a3968e023..b96d006224d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -395,6 +395,11 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/playwright-scenes": { + "name": "@bitwarden/playwright-scenes", + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/pricing": { "name": "@bitwarden/pricing", "version": "0.0.0", @@ -4685,6 +4690,10 @@ "resolved": "libs/platform", "link": true }, + "node_modules/@bitwarden/playwright-scenes": { + "resolved": "libs/playwright-scenes", + "link": true + }, "node_modules/@bitwarden/pricing": { "resolved": "libs/pricing", "link": true diff --git a/tsconfig.base.json b/tsconfig.base.json index 2d105d4263d..d2965df5593 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -49,6 +49,7 @@ "@bitwarden/nx-plugin": ["libs/nx-plugin/src/index.ts"], "@bitwarden/platform": ["./libs/platform/src"], "@bitwarden/platform/*": ["./libs/platform/src/*"], + "@bitwarden/playwright-scenes": ["libs/playwright-scenes/src/index.ts"], "@bitwarden/pricing": ["libs/pricing/src/index.ts"], "@bitwarden/send-ui": ["./libs/tools/send/send-ui/src"], "@bitwarden/serialization": ["libs/serialization/src/index.ts"],