test(e2e): isolated data per worker
This commit is contained in:
parent
b4668e23c7
commit
6354705626
6 changed files with 163 additions and 54 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -28,7 +28,7 @@ node_modules/
|
|||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
/playwright/restic.pass
|
||||
/playwright/*.pass
|
||||
|
||||
playwright/.auth
|
||||
playwright/temp
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
68
e2e/test.ts
68
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) => {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue