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/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/
/playwright/restic.pass /playwright/*.pass
playwright/.auth playwright/.auth
playwright/temp playwright/temp

View file

@ -1,12 +1,31 @@
import fs from "fs"; import fs from "fs";
import { test, expect } from "./test"; import { test, expect } from "./test";
import { resetDatabase } from "./helpers/db"; import { db, resetDatabase } from "./helpers/db";
import path from "node:path"; import path from "node:path";
import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
import { appMetadataTable } from "~/server/db/schema";
import { gotoAndWaitForAppReady } from "./helpers/page"; import { gotoAndWaitForAppReady } from "./helpers/page";
const authFile = path.join(process.cwd(), "./playwright/.auth/user.json"); 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 // Run tests in serial mode to avoid conflicts during onboarding
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
@ -16,9 +35,7 @@ test.beforeAll(async () => {
}); });
test("should redirect to onboarding", async ({ page }) => { test("should redirect to onboarding", async ({ page }) => {
await gotoAndWaitForAppReady(page, "/"); await gotoAndWaitForAppReady(page, "/onboarding");
await page.waitForURL(/onboarding/);
await expect(page).toHaveTitle(/Zerobyte - 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).toHaveURL("/volumes");
await expect(page.getByRole("heading", { name: "No volume" })).toBeVisible(); await expect(page.getByRole("heading", { name: "No volume" })).toBeVisible();
await enableRegistrations();
await page.context().storageState({ path: authFile }); 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)}`; 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 { function getScenarioNames(runId: string): ScenarioNames {
return { return {
volumeName: `Volume-${runId}`, volumeName: `Volume-${runId}`,
@ -44,7 +49,7 @@ function getScenarioNames(runId: string): ScenarioNames {
} }
function prepareTestFile(runId: string, fileName = "test.json"): string { 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 }); fs.mkdirSync(runPath, { recursive: true });
const filePath = path.join(runPath, fileName); const filePath = path.join(runPath, fileName);
@ -53,8 +58,15 @@ function prepareTestFile(runId: string, fileName = "test.json"): string {
return filePath; return filePath;
} }
async function createBackupScenario(page: Page, names: ScenarioNames, options: ScenarioOptions = {}) { async function createBackupScenario(page: Page, names: ScenarioNames, runId: string, options: ScenarioOptions = {}) {
await page.getByRole("button", { name: "Create Volume" }).click(); 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("textbox", { name: "Name" }).fill(names.volumeName);
await page.getByRole("button", { name: "test-data" }).click(); await page.getByRole("button", { name: "test-data" }).click();
await page.getByRole("button", { name: "Create Volume" }).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 gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes"); await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names); await createBackupScenario(page, names, runId);
await page.getByRole("button", { name: "Backup now" }).click(); await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible(); 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" })); fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" }));
await page await page
.getByRole("button", { name: /\d+ B$/ }) .getByRole("button", { name: /\d+(?:\.\d+)?\s(?:B|KiB|MiB|GiB|TiB)$/ })
.first() .first()
.click(); .click();
await page.getByRole("link", { name: "Restore" }).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) => { test("can restore a single selected file to a custom location", async ({ page }, testInfo) => {
const runId = getRunId(testInfo); const runId = getRunId(testInfo);
const names = getScenarioNames(runId); const names = getScenarioNames(runId);
const workerTestDataPath = getWorkerTestDataPath(runId);
const fileName = `single-file-${runId}.json`; const fileName = `single-file-${runId}.json`;
const filePath = prepareTestFile(runId, fileName); const filePath = prepareTestFile(runId, fileName);
const restoreTargetPath = path.join(testDataPath, fileName); const restoreTargetPath = path.join(workerTestDataPath, fileName);
const escapedFileName = fileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escapedFileName = fileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
fs.rmSync(restoreTargetPath, { force: true }); 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 gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes"); await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names); await createBackupScenario(page, names, runId);
await page.getByRole("button", { name: "Backup now" }).click(); await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible(); 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" })); fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" }));
await page await page
.getByRole("button", { name: /\d+ B$/ }) .getByRole("button", { name: /\d+(?:\.\d+)?\s(?:B|KiB|MiB|GiB|TiB)$/ })
.first() .first()
.click(); .click();
await page.getByRole("link", { name: "Restore" }).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 gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes"); await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names); await createBackupScenario(page, names, runId);
await page.getByRole("button", { name: "Backup now" }).click(); await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible(); 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 gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes"); await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names); await createBackupScenario(page, names, runId);
await page.getByRole("button", { name: "Backup now" }).click(); await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible(); 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) => { test("can download a selected snapshot directory as a tar archive", async ({ page }, testInfo) => {
const runId = getRunId(testInfo); const runId = getRunId(testInfo);
const names = getScenarioNames(runId); const names = getScenarioNames(runId);
const workerTestDataPath = getWorkerTestDataPath(runId);
const fileName = `download-${runId}.json`; const fileName = `download-${runId}.json`;
const filePath = prepareTestFile(runId, fileName); 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 }); fs.rmSync(downloadedPath, { force: true });
await gotoAndWaitForAppReady(page, "/"); await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes"); await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names); await createBackupScenario(page, names, runId);
await page.getByRole("button", { name: "Backup now" }).click(); await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible(); 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" })); fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" }));
await page await page
.getByRole("button", { name: /\d+ B$/ }) .getByRole("button", { name: /\d+(?:\.\d+)?\s(?:B|KiB|MiB|GiB|TiB)$/ })
.first() .first()
.click(); .click();
await page.getByRole("link", { name: "Restore" }).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 gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes"); await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names); await createBackupScenario(page, names, runId);
await gotoAndWaitForAppReady(page, "/backups"); await gotoAndWaitForAppReady(page, "/backups");
await page.getByText(names.backupName, { exact: true }).first().click(); 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) => { test("backup respects include globs, exclusion patterns, and exclude-if-present", async ({ page }, testInfo) => {
const runId = getRunId(testInfo); const runId = getRunId(testInfo);
const names = getScenarioNames(runId); const names = getScenarioNames(runId);
const workerTestDataPath = getWorkerTestDataPath(runId);
const keptDir = `kept-${runId}`; const keptDir = `kept-${runId}`;
const secondKeptDir = `second-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 secondRootDbFile = `archive-${runId}.db`;
const rootNonDbFile = `root-${runId}.txt`; const rootNonDbFile = `root-${runId}.txt`;
const keptPath = path.join(testDataPath, keptDir); const keptPath = path.join(workerTestDataPath, keptDir);
const secondKeptPath = path.join(testDataPath, secondKeptDir); const secondKeptPath = path.join(workerTestDataPath, secondKeptDir);
const blockedPath = path.join(testDataPath, blockedDir); const blockedPath = path.join(workerTestDataPath, blockedDir);
const globOnlyPath = path.join(testDataPath, globOnlyDir); const globOnlyPath = path.join(workerTestDataPath, globOnlyDir);
const dataPath = path.join(testDataPath, dataDir); const dataPath = path.join(workerTestDataPath, dataDir);
const configPath = path.join(testDataPath, configDir); const configPath = path.join(workerTestDataPath, configDir);
fs.mkdirSync(keptPath, { recursive: true }); fs.mkdirSync(keptPath, { recursive: true });
fs.mkdirSync(secondKeptPath, { 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, configExcludedFile), "json excluded by absolute exclude");
fs.writeFileSync(path.join(configPath, configNonJsonFile), "not included by /config/*.json"); fs.writeFileSync(path.join(configPath, configNonJsonFile), "not included by /config/*.json");
fs.writeFileSync(path.join(testDataPath, rootDbFile), "root db include"); fs.writeFileSync(path.join(workerTestDataPath, rootDbFile), "root db include");
fs.writeFileSync(path.join(testDataPath, secondRootDbFile), "second root db include"); fs.writeFileSync(path.join(workerTestDataPath, secondRootDbFile), "second root db include");
fs.writeFileSync(path.join(testDataPath, rootNonDbFile), "root non-db exclude"); fs.writeFileSync(path.join(workerTestDataPath, rootNonDbFile), "root non-db exclude");
await gotoAndWaitForAppReady(page, "/"); await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes"); await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, { await createBackupScenario(page, names, runId, {
includePatterns: [ includePatterns: [
`/${keptDir}`, `/${keptDir}`,
`/${secondKeptDir}`, `/${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) => { test("backup can include a selected folder whose name contains brackets", async ({ page }, testInfo) => {
const runId = getRunId(testInfo); const runId = getRunId(testInfo);
const names = getScenarioNames(runId); const names = getScenarioNames(runId);
const workerTestDataPath = getWorkerTestDataPath(runId);
const bracketDir = `movies [${runId}]`; const bracketDir = `movies [${runId}]`;
const bracketPath = path.join(testDataPath, bracketDir); const bracketPath = path.join(workerTestDataPath, bracketDir);
const fileName = `inside-${runId}.txt`; const fileName = `inside-${runId}.txt`;
fs.mkdirSync(bracketPath, { recursive: true }); 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 gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes"); await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, { await createBackupScenario(page, names, runId, {
selectedPaths: [`/${bracketDir}`], selectedPaths: [`/${bracketDir}`],
}); });

View file

@ -1,3 +1,4 @@
import path from "node:path";
import { type Browser, type Page } from "@playwright/test"; import { type Browser, type Page } from "@playwright/test";
import { expect, test } from "./test"; import { expect, test } from "./test";
import { trackBrowserErrors } from "./helpers/browser-errors"; 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 issuer = `${dexOrigin}/dex`;
const discoveryEndpoint = `${issuer}/.well-known/openid-configuration`; const discoveryEndpoint = `${issuer}/.well-known/openid-configuration`;
const appBaseUrl = `http://${process.env.SERVER_IP ?? "localhost"}:4096`; const appBaseUrl = `http://${process.env.SERVER_IP ?? "localhost"}:4096`;
const setupAuthFile = path.join(process.cwd(), "playwright", ".auth", "user.json");
const providerIds = { const providerIds = {
uninvited: "test-oidc-uninvited", uninvited: "test-oidc-uninvited",
@ -83,25 +85,34 @@ async function createPendingInvitation(page: Page, email: string) {
} }
} }
async function createLocalUser(page: Page, email: string, username: string) { async function createLocalUser(browser: Browser, email: string, username: string) {
const response = await page.request.post("/api/auth/admin/create-user", { const adminContext = await browser.newContext({
headers: { baseURL: appBaseUrl,
Origin: appBaseUrl, storageState: setupAuthFile,
},
data: {
email,
password: dexPassword,
name: "SSO Link Target",
role: "user",
data: {
username,
hasDownloadedResticPassword: true,
},
},
}); });
if (!response.ok()) { try {
throw new Error(`Failed to create local user ${email}: ${await response.text()}`); 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 }) => { test("auto-link policy enforces invitation and controls account linking", async ({ page, browser }) => {
await registerOidcProvider(page, providerIds.autoLinkNoInvite); await registerOidcProvider(page, providerIds.autoLinkNoInvite);
await createLocalUser(page, autoLinkUninvitedLocalEmail, autoLinkUninvitedLocalUsername); await createLocalUser(browser, autoLinkUninvitedLocalEmail, autoLinkUninvitedLocalUsername);
await setProviderAutoLinking(page, providerIds.autoLinkNoInvite, true); await setProviderAutoLinking(page, providerIds.autoLinkNoInvite, true);
await withOidcLoginAttempt(browser, providerIds.autoLinkNoInvite, autoLinkUninvitedLocalEmail, async (ssoPage) => { 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 registerOidcProvider(page, providerIds.autoLink);
await createLocalUser(page, autoLinkTargetEmail, autoLinkTargetUsername); await createLocalUser(browser, autoLinkTargetEmail, autoLinkTargetUsername);
await createPendingInvitation(page, autoLinkTargetEmail); await createPendingInvitation(page, autoLinkTargetEmail);
await setProviderAutoLinking(page, providerIds.autoLink, false); 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 { 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) => { context: async ({ context }, use, testInfo) => {
const browserErrorTracker = trackBrowserErrors(context, { const browserErrorTracker = trackBrowserErrors(context, {
attach: async (name, body, contentType) => { attach: async (name, body, contentType) => {

View file

@ -23,7 +23,6 @@ export default defineConfig({
name: "chromium", name: "chromium",
use: { use: {
...devices["Desktop Chrome"], ...devices["Desktop Chrome"],
storageState: "playwright/.auth/user.json",
launchOptions: { launchOptions: {
args: ["--host-rules=MAP dex 127.0.0.1"], args: ["--host-rules=MAP dex 127.0.0.1"],
}, },