From 635470562667e46196b1f8ddebe1d9d101094ef6 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 24 Mar 2026 07:52:51 +0100 Subject: [PATCH] test(e2e): isolated data per worker --- .gitignore | 2 +- e2e/0001-auth.setup.ts | 29 +++++++++++--- e2e/0002-backup-restore.spec.ts | 68 ++++++++++++++++++++------------- e2e/0003-oidc.spec.ts | 49 +++++++++++++++--------- e2e/test.ts | 68 ++++++++++++++++++++++++++++++++- playwright.config.ts | 1 - 6 files changed, 163 insertions(+), 54 deletions(-) diff --git a/.gitignore b/.gitignore index bd675bcc..043bfa26 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ node_modules/ /blob-report/ /playwright/.cache/ /playwright/.auth/ -/playwright/restic.pass +/playwright/*.pass playwright/.auth playwright/temp diff --git a/e2e/0001-auth.setup.ts b/e2e/0001-auth.setup.ts index 61fe9379..24081b63 100644 --- a/e2e/0001-auth.setup.ts +++ b/e2e/0001-auth.setup.ts @@ -1,12 +1,31 @@ import fs from "fs"; import { test, expect } from "./test"; -import { resetDatabase } from "./helpers/db"; +import { db, resetDatabase } from "./helpers/db"; import path from "node:path"; +import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants"; +import { appMetadataTable } from "~/server/db/schema"; import { gotoAndWaitForAppReady } from "./helpers/page"; const authFile = path.join(process.cwd(), "./playwright/.auth/user.json"); +const enableRegistrations = async () => { + const now = Date.now(); -// TODO: Run these tests with different users once multi-user support is added + await db + .insert(appMetadataTable) + .values({ + key: REGISTRATION_ENABLED_KEY, + value: JSON.stringify(true), + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: appMetadataTable.key, + set: { + value: JSON.stringify(true), + updatedAt: now, + }, + }); +}; // Run tests in serial mode to avoid conflicts during onboarding test.describe.configure({ mode: "serial" }); @@ -16,9 +35,7 @@ test.beforeAll(async () => { }); test("should redirect to onboarding", async ({ page }) => { - await gotoAndWaitForAppReady(page, "/"); - - await page.waitForURL(/onboarding/); + await gotoAndWaitForAppReady(page, "/onboarding"); await expect(page).toHaveTitle(/Zerobyte - Onboarding/); }); @@ -95,5 +112,7 @@ test("can login after initial setup", async ({ page }) => { await expect(page).toHaveURL("/volumes"); await expect(page.getByRole("heading", { name: "No volume" })).toBeVisible(); + await enableRegistrations(); + await page.context().storageState({ path: authFile }); }); diff --git a/e2e/0002-backup-restore.spec.ts b/e2e/0002-backup-restore.spec.ts index 65760e57..be71ef20 100644 --- a/e2e/0002-backup-restore.spec.ts +++ b/e2e/0002-backup-restore.spec.ts @@ -35,6 +35,11 @@ function getRunId(testInfo: TestInfo) { return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`; } +function getWorkerTestDataPath(runId: string) { + fs.mkdirSync(testDataPath, { recursive: true }); + return testDataPath; +} + function getScenarioNames(runId: string): ScenarioNames { return { volumeName: `Volume-${runId}`, @@ -44,7 +49,7 @@ function getScenarioNames(runId: string): ScenarioNames { } function prepareTestFile(runId: string, fileName = "test.json"): string { - const runPath = path.join(testDataPath, runId); + const runPath = path.join(getWorkerTestDataPath(runId), runId); fs.mkdirSync(runPath, { recursive: true }); const filePath = path.join(runPath, fileName); @@ -53,8 +58,15 @@ function prepareTestFile(runId: string, fileName = "test.json"): string { return filePath; } -async function createBackupScenario(page: Page, names: ScenarioNames, options: ScenarioOptions = {}) { - await page.getByRole("button", { name: "Create Volume" }).click(); +async function createBackupScenario(page: Page, names: ScenarioNames, runId: string, options: ScenarioOptions = {}) { + getWorkerTestDataPath(runId); + + const volumeNameInput = page.getByRole("textbox", { name: "Name" }); + await expect(async () => { + await page.getByRole("button", { name: "Create Volume" }).click(); + await expect(volumeNameInput).toBeVisible(); + }).toPass({ timeout: 10000 }); + await page.getByRole("textbox", { name: "Name" }).fill(names.volumeName); await page.getByRole("button", { name: "test-data" }).click(); await page.getByRole("button", { name: "Create Volume" }).click(); @@ -141,7 +153,7 @@ test("can backup & restore a file", async ({ page }, testInfo) => { await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names); + await createBackupScenario(page, names, runId); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -150,7 +162,7 @@ test("can backup & restore a file", async ({ page }, testInfo) => { fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" })); await page - .getByRole("button", { name: /\d+ B$/ }) + .getByRole("button", { name: /\d+(?:\.\d+)?\s(?:B|KiB|MiB|GiB|TiB)$/ }) .first() .click(); await page.getByRole("link", { name: "Restore" }).click(); @@ -165,9 +177,10 @@ test("can backup & restore a file", async ({ page }, testInfo) => { test("can restore a single selected file to a custom location", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); + const workerTestDataPath = getWorkerTestDataPath(runId); const fileName = `single-file-${runId}.json`; const filePath = prepareTestFile(runId, fileName); - const restoreTargetPath = path.join(testDataPath, fileName); + const restoreTargetPath = path.join(workerTestDataPath, fileName); const escapedFileName = fileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); fs.rmSync(restoreTargetPath, { force: true }); @@ -175,7 +188,7 @@ test("can restore a single selected file to a custom location", async ({ page }, await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names); + await createBackupScenario(page, names, runId); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -184,7 +197,7 @@ test("can restore a single selected file to a custom location", async ({ page }, fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" })); await page - .getByRole("button", { name: /\d+ B$/ }) + .getByRole("button", { name: /\d+(?:\.\d+)?\s(?:B|KiB|MiB|GiB|TiB)$/ }) .first() .click(); await page.getByRole("link", { name: "Restore" }).click(); @@ -221,7 +234,7 @@ test("can re-tag a snapshot to another backup schedule", async ({ page }, testIn await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names); + await createBackupScenario(page, names, runId); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -264,7 +277,7 @@ test("can delete a snapshot from the repository snapshots tab", async ({ page }, await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names); + await createBackupScenario(page, names, runId); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -289,16 +302,17 @@ test("can delete a snapshot from the repository snapshots tab", async ({ page }, test("can download a selected snapshot directory as a tar archive", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); + const workerTestDataPath = getWorkerTestDataPath(runId); const fileName = `download-${runId}.json`; const filePath = prepareTestFile(runId, fileName); - const downloadedPath = path.join(testDataPath, `downloaded-${runId}.tar`); + const downloadedPath = path.join(workerTestDataPath, `downloaded-${runId}.tar`); fs.rmSync(downloadedPath, { force: true }); await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names); + await createBackupScenario(page, names, runId); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -307,7 +321,7 @@ test("can download a selected snapshot directory as a tar archive", async ({ pag fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" })); await page - .getByRole("button", { name: /\d+ B$/ }) + .getByRole("button", { name: /\d+(?:\.\d+)?\s(?:B|KiB|MiB|GiB|TiB)$/ }) .first() .click(); await page.getByRole("link", { name: "Restore" }).click(); @@ -337,7 +351,7 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names); + await createBackupScenario(page, names, runId); await gotoAndWaitForAppReady(page, "/backups"); await page.getByText(names.backupName, { exact: true }).first().click(); @@ -364,6 +378,7 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page test("backup respects include globs, exclusion patterns, and exclude-if-present", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); + const workerTestDataPath = getWorkerTestDataPath(runId); const keptDir = `kept-${runId}`; const secondKeptDir = `second-kept-${runId}`; @@ -380,12 +395,12 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present" const secondRootDbFile = `archive-${runId}.db`; const rootNonDbFile = `root-${runId}.txt`; - const keptPath = path.join(testDataPath, keptDir); - const secondKeptPath = path.join(testDataPath, secondKeptDir); - const blockedPath = path.join(testDataPath, blockedDir); - const globOnlyPath = path.join(testDataPath, globOnlyDir); - const dataPath = path.join(testDataPath, dataDir); - const configPath = path.join(testDataPath, configDir); + const keptPath = path.join(workerTestDataPath, keptDir); + const secondKeptPath = path.join(workerTestDataPath, secondKeptDir); + const blockedPath = path.join(workerTestDataPath, blockedDir); + const globOnlyPath = path.join(workerTestDataPath, globOnlyDir); + const dataPath = path.join(workerTestDataPath, dataDir); + const configPath = path.join(workerTestDataPath, configDir); fs.mkdirSync(keptPath, { recursive: true }); fs.mkdirSync(secondKeptPath, { recursive: true }); @@ -411,14 +426,14 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present" fs.writeFileSync(path.join(configPath, configExcludedFile), "json excluded by absolute exclude"); fs.writeFileSync(path.join(configPath, configNonJsonFile), "not included by /config/*.json"); - fs.writeFileSync(path.join(testDataPath, rootDbFile), "root db include"); - fs.writeFileSync(path.join(testDataPath, secondRootDbFile), "second root db include"); - fs.writeFileSync(path.join(testDataPath, rootNonDbFile), "root non-db exclude"); + fs.writeFileSync(path.join(workerTestDataPath, rootDbFile), "root db include"); + fs.writeFileSync(path.join(workerTestDataPath, secondRootDbFile), "second root db include"); + fs.writeFileSync(path.join(workerTestDataPath, rootNonDbFile), "root non-db exclude"); await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, { + await createBackupScenario(page, names, runId, { includePatterns: [ `/${keptDir}`, `/${secondKeptDir}`, @@ -477,8 +492,9 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present" test("backup can include a selected folder whose name contains brackets", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); + const workerTestDataPath = getWorkerTestDataPath(runId); const bracketDir = `movies [${runId}]`; - const bracketPath = path.join(testDataPath, bracketDir); + const bracketPath = path.join(workerTestDataPath, bracketDir); const fileName = `inside-${runId}.txt`; fs.mkdirSync(bracketPath, { recursive: true }); @@ -487,7 +503,7 @@ test("backup can include a selected folder whose name contains brackets", async await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, { + await createBackupScenario(page, names, runId, { selectedPaths: [`/${bracketDir}`], }); diff --git a/e2e/0003-oidc.spec.ts b/e2e/0003-oidc.spec.ts index c028fac2..3783483a 100644 --- a/e2e/0003-oidc.spec.ts +++ b/e2e/0003-oidc.spec.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { type Browser, type Page } from "@playwright/test"; import { expect, test } from "./test"; import { trackBrowserErrors } from "./helpers/browser-errors"; @@ -7,6 +8,7 @@ const dexOrigin = process.env.E2E_DEX_ORIGIN ?? "http://dex:5557"; const issuer = `${dexOrigin}/dex`; const discoveryEndpoint = `${issuer}/.well-known/openid-configuration`; const appBaseUrl = `http://${process.env.SERVER_IP ?? "localhost"}:4096`; +const setupAuthFile = path.join(process.cwd(), "playwright", ".auth", "user.json"); const providerIds = { uninvited: "test-oidc-uninvited", @@ -83,25 +85,34 @@ async function createPendingInvitation(page: Page, email: string) { } } -async function createLocalUser(page: Page, email: string, username: string) { - const response = await page.request.post("/api/auth/admin/create-user", { - headers: { - Origin: appBaseUrl, - }, - data: { - email, - password: dexPassword, - name: "SSO Link Target", - role: "user", - data: { - username, - hasDownloadedResticPassword: true, - }, - }, +async function createLocalUser(browser: Browser, email: string, username: string) { + const adminContext = await browser.newContext({ + baseURL: appBaseUrl, + storageState: setupAuthFile, }); - if (!response.ok()) { - throw new Error(`Failed to create local user ${email}: ${await response.text()}`); + try { + const response = await adminContext.request.post("/api/auth/admin/create-user", { + headers: { + Origin: appBaseUrl, + }, + data: { + email, + password: dexPassword, + name: "SSO Link Target", + role: "user", + data: { + username, + hasDownloadedResticPassword: true, + }, + }, + }); + + if (!response.ok()) { + throw new Error(`Failed to create local user ${email}: ${await response.text()}`); + } + } finally { + await adminContext.close(); } } @@ -338,7 +349,7 @@ test("invited OIDC users can sign in, retain access, and are blocked after remov test("auto-link policy enforces invitation and controls account linking", async ({ page, browser }) => { await registerOidcProvider(page, providerIds.autoLinkNoInvite); - await createLocalUser(page, autoLinkUninvitedLocalEmail, autoLinkUninvitedLocalUsername); + await createLocalUser(browser, autoLinkUninvitedLocalEmail, autoLinkUninvitedLocalUsername); await setProviderAutoLinking(page, providerIds.autoLinkNoInvite, true); await withOidcLoginAttempt(browser, providerIds.autoLinkNoInvite, autoLinkUninvitedLocalEmail, async (ssoPage) => { @@ -346,7 +357,7 @@ test("auto-link policy enforces invitation and controls account linking", async }); await registerOidcProvider(page, providerIds.autoLink); - await createLocalUser(page, autoLinkTargetEmail, autoLinkTargetUsername); + await createLocalUser(browser, autoLinkTargetEmail, autoLinkTargetUsername); await createPendingInvitation(page, autoLinkTargetEmail); await setProviderAutoLinking(page, providerIds.autoLink, false); diff --git a/e2e/test.ts b/e2e/test.ts index 49a2a40f..dda76f97 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -1,7 +1,71 @@ -import { expect, test as base } from "@playwright/test"; +import fs from "node:fs"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { type Browser, expect, test as base } from "@playwright/test"; import { trackBrowserErrors } from "./helpers/browser-errors"; +import { gotoAndWaitForAppReady } from "./helpers/page"; -export const test = base.extend({ +const workerAuthDir = path.join(process.cwd(), "playwright", ".auth", "workers"); + +type WorkerFixtures = { + workerStorageState: string | undefined; +}; + +const createWorkerStorageState = async (browser: Browser, parallelIndex: number) => { + const workerId = `worker-${parallelIndex}-${randomUUID().slice(0, 8)}`; + const username = `e2e-${workerId}`; + const email = `${username}@example.com`; + const password = "password123"; + + const workerStorageStatePath = path.join(workerAuthDir, `worker-${parallelIndex}.json`); + const recoveryKeyPath = path.join(process.cwd(), "playwright", `restic-${workerId}.pass`); + const baseURL = `http://${process.env.SERVER_IP}:4096`; + + fs.mkdirSync(workerAuthDir, { recursive: true }); + + const context = await browser.newContext({ baseURL }); + try { + const page = await context.newPage(); + + await gotoAndWaitForAppReady(page, "/onboarding"); + + await page.getByRole("textbox", { name: "Email" }).fill(email); + await page.getByRole("textbox", { name: "Username" }).fill(username); + await page.getByRole("textbox", { name: "Password", exact: true }).fill(password); + await page.getByRole("textbox", { name: "Confirm Password" }).fill(password); + await page.getByRole("button", { name: "Create admin user" }).click(); + await expect(page.getByText("Download Your Recovery Key")).toBeVisible(); + + await page.getByRole("textbox", { name: "Confirm Your Password" }).fill(password); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Download Recovery Key" }).click(); + const download = await downloadPromise; + await download.saveAs(recoveryKeyPath); + await expect(page).toHaveURL("/volumes"); + + await context.storageState({ path: workerStorageStatePath }); + return workerStorageStatePath; + } finally { + await context.close(); + } +}; + +export const test = base.extend<{}, WorkerFixtures>({ + workerStorageState: [ + async ({ browser }, use, workerInfo) => { + if (workerInfo.project.name === "setup") { + await use(undefined); + return; + } + + const storageStatePath = await createWorkerStorageState(browser, workerInfo.parallelIndex); + await use(storageStatePath); + }, + { scope: "worker" }, + ], + storageState: async ({ workerStorageState }, use) => { + await use(workerStorageState); + }, context: async ({ context }, use, testInfo) => { const browserErrorTracker = trackBrowserErrors(context, { attach: async (name, body, contentType) => { diff --git a/playwright.config.ts b/playwright.config.ts index 6401b4ec..1e488574 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,7 +23,6 @@ export default defineConfig({ name: "chromium", use: { ...devices["Desktop Chrome"], - storageState: "playwright/.auth/user.json", launchOptions: { args: ["--host-rules=MAP dex 127.0.0.1"], },