test(e2e): isolated data per worker

This commit is contained in:
Nicolas Meienberger 2026-03-24 07:52:51 +01:00
parent b4668e23c7
commit 6354705626
6 changed files with 163 additions and 54 deletions

2
.gitignore vendored
View file

@ -28,7 +28,7 @@ node_modules/
/blob-report/
/playwright/.cache/
/playwright/.auth/
/playwright/restic.pass
/playwright/*.pass
playwright/.auth
playwright/temp

View file

@ -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 });
});

View file

@ -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}`],
});

View file

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

View file

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

View file

@ -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"],
},