Files
zerobyte/e2e/0004-mirror-sync.spec.ts
2026-05-29 20:45:16 +02:00

201 lines
8.8 KiB
TypeScript

import fs from "node:fs";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { type Page, type TestInfo } from "@playwright/test";
import { expect, test } from "./test";
import { gotoAndWaitForAppReady } from "./helpers/page";
const testDataPath = path.join(process.cwd(), "playwright", "temp");
type RepositorySummary = {
name: string;
shortId: string;
};
type MirrorAssignmentSummary = {
repositoryId: string;
lastCopyStatus: "success" | "error" | "in_progress" | null;
};
type MirrorSyncStatus = {
sourceCount: number;
mirrorCount: number;
missingSnapshots: unknown[];
};
function getRunId(testInfo: TestInfo) {
return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
}
function getBackupShortId(backupPageUrl: string) {
const backupShortId = new URL(backupPageUrl).pathname.split("/").filter(Boolean).at(-1);
expect(backupShortId).toBeTruthy();
return backupShortId!;
}
function getWorkerTestDataPath() {
fs.mkdirSync(testDataPath, { recursive: true });
return testDataPath;
}
async function listRepositories(page: Page) {
const response = await page.request.get("/api/v1/repositories");
expect(response.ok()).toBe(true);
return (await response.json()) as RepositorySummary[];
}
async function getRepositoryShortId(page: Page, name: string) {
const repositories = await listRepositories(page);
const repository = repositories.find((entry) => entry.name === name);
expect(repository).toBeDefined();
return repository!.shortId;
}
async function createRepository(page: Page, name: string) {
await gotoAndWaitForAppReady(page, "/repositories/create");
await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible();
await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("combobox", { name: "Backend" }).click();
await page.getByRole("option", { name: "Local" }).click();
await page.getByRole("button", { name: "Create repository" }).click();
await expect(page.getByText("Repository created successfully")).toBeVisible({ timeout: 30000 });
}
async function createVolume(page: Page, name: string) {
await gotoAndWaitForAppReady(page, "/volumes");
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 volumeNameInput.fill(name);
await page.getByRole("button", { name: "test-data" }).click();
await page.getByRole("button", { name: "Create Volume" }).click();
await expect(page.getByText("Volume created successfully")).toBeVisible();
}
async function createBackupJob(page: Page, backupName: string, volumeName: string, repositoryName: string) {
await gotoAndWaitForAppReady(page, "/backups");
const createBackupButton = page.getByRole("button", { name: "Create a backup job" }).first();
if (await createBackupButton.isVisible()) {
await createBackupButton.click();
} else {
await page.getByRole("link", { name: "Create a backup job" }).first().click();
}
const volumeSelect = page.getByRole("combobox").filter({ hasText: "Choose a volume to backup" });
const volumeOption = page.getByRole("option", { name: volumeName });
await expect(async () => {
await volumeSelect.click();
await expect(volumeOption).toBeVisible();
}).toPass({ timeout: 10000 });
await volumeOption.click();
await page.getByRole("textbox", { name: "Backup name" }).fill(backupName);
await page.getByRole("combobox").filter({ hasText: "Select a repository" }).click();
await page.getByRole("option", { name: repositoryName }).click();
await page.getByRole("combobox").filter({ hasText: "Select frequency" }).click();
await page.getByRole("option", { name: "Daily" }).click();
await page.getByRole("textbox", { name: "Execution time" }).fill("00:00");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByText("Backup job created successfully")).toBeVisible();
}
async function waitForMirrorSyncComplete(page: Page, backupShortId: string, mirrorRepoShortId: string) {
await expect(async () => {
const mirrorsResponse = await page.request.get(`/api/v1/backups/${backupShortId}/mirrors`);
expect(mirrorsResponse.ok()).toBe(true);
const mirrors = (await mirrorsResponse.json()) as MirrorAssignmentSummary[];
const mirror = mirrors.find((entry) => entry.repositoryId === mirrorRepoShortId);
expect(mirror?.lastCopyStatus).toBe("success");
const statusResponse = await page.request.get(
`/api/v1/backups/${backupShortId}/mirrors/${mirrorRepoShortId}/status`,
);
expect(statusResponse.ok()).toBe(true);
const status = (await statusResponse.json()) as MirrorSyncStatus;
expect(status.sourceCount).toBe(1);
expect(status.mirrorCount).toBe(1);
expect(status.missingSnapshots).toHaveLength(0);
}).toPass({ timeout: 30000 });
}
test("can sync missing snapshots to a mirror repository", async ({ page }, testInfo) => {
const runId = getRunId(testInfo);
const volumeName = `Volume-${runId}`;
const primaryRepoName = `Primary-${runId}`;
const mirrorRepoName = `Mirror-${runId}`;
const backupName = `Backup-${runId}`;
const workerTestDataPath = getWorkerTestDataPath();
const runPath = path.join(workerTestDataPath, runId);
fs.mkdirSync(runPath, { recursive: true });
fs.writeFileSync(path.join(runPath, "test.json"), JSON.stringify({ data: "mirror sync test" }));
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
// Create volume, two repositories, and a backup job
await createVolume(page, volumeName);
await createRepository(page, primaryRepoName);
await createRepository(page, mirrorRepoName);
await createBackupJob(page, backupName, volumeName, primaryRepoName);
const backupPageUrl = page.url();
const backupShortId = getBackupShortId(backupPageUrl);
const primaryRepoShortId = await getRepositoryShortId(page, primaryRepoName);
const mirrorRepoShortId = await getRepositoryShortId(page, mirrorRepoName);
// Run a backup to create a snapshot and wait for it to persist.
await page.getByRole("button", { name: "Backup now" }).click();
await gotoAndWaitForAppReady(page, `/repositories/${primaryRepoShortId}`);
await page.getByRole("tab", { name: "Snapshots" }).click();
await expect(page.getByText("Backup snapshots stored in this repository.")).toBeVisible();
await expect(async () => {
await page.getByRole("button", { name: "Refresh" }).click();
await expect(page.getByRole("checkbox", { name: /Select snapshot/ })).toHaveCount(1);
}).toPass({ timeout: 30000 });
await gotoAndWaitForAppReady(page, backupPageUrl);
// Add mirror repository
await page.getByRole("button", { name: "Add mirror" }).click();
const mirrorSelect = page.getByRole("combobox").filter({ hasText: "Select a repository to mirror to..." });
await mirrorSelect.click();
await page.getByRole("option", { name: mirrorRepoName }).click();
await page.getByRole("button", { name: "Save changes" }).click();
await expect(page.getByRole("button", { name: "Save changes" })).toHaveCount(0);
// Click sync button on the mirror row (first icon button in the actions cell)
const mirrorRow = page.getByRole("row").filter({ hasText: mirrorRepoName });
await expect(mirrorRow).toBeVisible();
await mirrorRow.getByRole("button").first().click();
// Verify the sync dialog shows missing snapshots
const syncDialog = page.getByRole("dialog");
await expect(syncDialog.getByRole("heading", { name: "Sync snapshots" })).toBeVisible();
await expect(syncDialog.getByText(/1 of 1 snapshots are missing/)).toBeVisible({ timeout: 15000 });
// Select the missing snapshots explicitly before syncing.
await expect(syncDialog.getByRole("button", { name: "Sync 0 snapshots" })).toBeDisabled();
await syncDialog.getByRole("checkbox").first().click();
const syncButton = syncDialog.getByRole("button", { name: "Sync 1 snapshots" });
await expect(syncButton).toBeEnabled();
// Click sync and wait on the mirror status that backs this dialog and row.
await syncButton.click();
await waitForMirrorSyncComplete(page, backupShortId, mirrorRepoShortId);
await gotoAndWaitForAppReady(page, backupPageUrl);
// Open sync dialog again and verify all snapshots are synced
await expect(async () => {
await expect(mirrorRow).toBeVisible();
await mirrorRow.getByRole("button").first().click();
await expect(syncDialog.getByRole("heading", { name: "Sync snapshots" })).toBeVisible();
}).toPass({ timeout: 15000 });
await expect(syncDialog.getByText(/All 1 snapshots are already synced/)).toBeVisible({ timeout: 15000 });
await syncDialog.getByRole("button", { name: "Cancel" }).click();
// Verify the synced snapshot remains visible in the mirror repository UI.
await gotoAndWaitForAppReady(page, `/repositories/${mirrorRepoShortId}`);
await page.getByRole("tab", { name: "Snapshots" }).click();
await expect(page.getByRole("checkbox", { name: /Select snapshot/ })).toHaveCount(1);
});