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 ScenarioNames = { volumeName: string; repositoryName: string; backupName: string; }; type BackupSelectionOptions = { includePatterns?: string; excludePatterns?: string; excludeIfPresent?: string; selectedPaths?: string[]; }; type ScenarioOptions = BackupSelectionOptions & { repositoryBasePath?: string; }; type BackupJobOptions = BackupSelectionOptions & { backupName: string; volumeName: string; repositoryName: string; }; type RepositoryListItem = { name: string; shortId: string; }; type RepositoryDetail = RepositoryListItem & { config: { backend: string; path?: string; isExistingRepository?: boolean; }; status: "healthy" | "error" | "unknown" | "doctor" | "cancelled" | null; }; type SnapshotListItem = { id: string; short_id: string; tags: string[]; }; type ImportMode = { slug: "recovery-key" | "manual-password"; shortName: "rk" | "pw"; testName: string; useCustomPassword: boolean; }; const importModes: ImportMode[] = [ { slug: "recovery-key", shortName: "rk", testName: "can import an existing Zerobyte-created repository with the recovery key", useCustomPassword: false, }, { slug: "manual-password", shortName: "pw", testName: "can import an existing Zerobyte-created repository with a manual password", useCustomPassword: true, }, ]; function getRunId(testInfo: TestInfo) { return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`; } function getWorkerTestDataPath() { fs.mkdirSync(testDataPath, { recursive: true }); return testDataPath; } function getScenarioNames(runId: string): ScenarioNames { return { volumeName: `Volume-${runId}`, repositoryName: `Repo-${runId}`, backupName: `Backup-${runId}`, }; } function prepareTestFile(runId: string, fileName = "test.json"): string { const runPath = path.join(getWorkerTestDataPath(), runId); fs.mkdirSync(runPath, { recursive: true }); const filePath = path.join(runPath, fileName); fs.writeFileSync(filePath, JSON.stringify({ data: "test file" })); return filePath; } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } async function createBackupScenario(page: Page, names: ScenarioNames, options: ScenarioOptions = {}) { const workerTestDataPath = getWorkerTestDataPath(); const { repositoryBasePath, ...backupOptions } = options; const scenarioPath = names.volumeName.replace(/^Volume-/, ""); const selectedPaths = backupOptions.selectedPaths ?? (backupOptions.includePatterns || !fs.existsSync(path.join(workerTestDataPath, scenarioPath)) ? undefined : [`/${scenarioPath}`]); await createVolume(page, names.volumeName); await createRepository(page, names.repositoryName, repositoryBasePath); await createBackupJob(page, { backupName: names.backupName, volumeName: names.volumeName, repositoryName: names.repositoryName, selectedPaths, ...backupOptions, }); } async function createVolume(page: Page, volumeName: 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 page.getByRole("textbox", { name: "Name" }).fill(volumeName); 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 createRepository(page: Page, repositoryName: string, repositoryBasePath?: string) { await gotoAndWaitForAppReady(page, "/repositories/create"); await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible(); await page.getByRole("textbox", { name: "Name" }).fill(repositoryName); await page.getByRole("combobox", { name: "Backend" }).click(); await page.getByRole("option", { name: "Local" }).click(); if (repositoryBasePath) { await selectRepositoryDirectory(page, repositoryBasePath); } await page.getByRole("button", { name: "Create repository" }).click(); await expect(page.getByText("Repository created successfully")).toBeVisible({ timeout: 30000 }); } async function selectRepositoryDirectory(page: Page, directoryPath: string) { const segments = directoryPath.split("/").filter(Boolean); await page.getByRole("button", { name: "Change" }).click(); await page.getByRole("button", { name: "I Understand, Continue" }).click(); const dialog = page.getByRole("alertdialog"); await expect(dialog.getByRole("heading", { name: "Select Repository Directory" })).toBeVisible(); for (const [index, segment] of segments.entries()) { const folderRow = dialog.getByRole("button", { name: segment, exact: true }); await expect(folderRow).toBeVisible({ timeout: 10000 }); if (index === segments.length - 1) { await folderRow.click(); } else { await folderRow.locator("svg").first().click(); } } await expect(dialog.getByText(directoryPath, { exact: true })).toBeVisible(); await dialog.getByRole("button", { name: "Done" }).click(); } async function createBackupJob(page: Page, options: BackupJobOptions) { 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: options.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(options.backupName); await page.getByRole("combobox").filter({ hasText: "Select a repository" }).click(); await page.getByRole("option", { name: options.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"); if (options.includePatterns) { await page.getByLabel("Additional include patterns").fill(options.includePatterns); } if (options.selectedPaths) { for (const selectedPath of options.selectedPaths) { const escapedPath = escapeRegExp(path.posix.basename(selectedPath)); await page .getByRole("button", { name: new RegExp(escapedPath) }) .getByRole("checkbox") .click(); } } if (options.excludePatterns) { await page.getByLabel("Exclusion patterns").fill(options.excludePatterns); } if (options.excludeIfPresent) { await page.getByLabel("Exclude if file present").fill(options.excludeIfPresent); } await page.getByRole("button", { name: "Create" }).click(); await expect(page.getByText("Backup job created successfully")).toBeVisible(); } async function listRepositories(page: Page) { const response = await page.request.get("/api/v1/repositories"); expect(response.ok()).toBe(true); return (await response.json()) as RepositoryListItem[]; } async function getRepositoryByName(page: Page, repositoryName: string) { const repositories = await listRepositories(page); const repository = repositories.find((entry) => entry.name === repositoryName); expect(repository).toBeDefined(); return repository!; } async function getRepositoryDetail(page: Page, repositoryShortId: string) { const response = await page.request.get(`/api/v1/repositories/${repositoryShortId}`); expect(response.ok()).toBe(true); return (await response.json()) as RepositoryDetail; } function getLocalRepositoryPath(repository: RepositoryDetail) { expect(repository.config.backend).toBe("local"); expect(repository.config.path).toBeTruthy(); return repository.config.path!; } async function waitForRepositorySnapshots(page: Page, repositoryShortId: string, expectedCount: number) { let snapshots: SnapshotListItem[] = []; await expect(async () => { const response = await page.request.get(`/api/v1/repositories/${repositoryShortId}/snapshots`); expect(response.ok()).toBe(true); snapshots = (await response.json()) as SnapshotListItem[]; expect(snapshots).toHaveLength(expectedCount); }).toPass({ timeout: 30000 }); return snapshots; } async function deleteRepositoryConfig(page: Page, repositoryShortId: string) { const response = await page.request.delete(`/api/v1/repositories/${repositoryShortId}`); expect(response.ok()).toBe(true); await expect(async () => { const repositories = await listRepositories(page); expect(repositories.find((entry) => entry.shortId === repositoryShortId)).toBeUndefined(); }).toPass({ timeout: 10000 }); } async function downloadResticPassword(page: Page) { const response = await page.request.post("/api/v1/system/restic-password", { data: { password: "password123", }, }); expect(response.ok()).toBe(true); return (await response.text()).trim(); } async function importExistingLocalRepository( page: Page, repositoryName: string, repositoryPath: string, customPassword?: string, ) { await gotoAndWaitForAppReady(page, "/repositories/create"); await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible(); await page.getByRole("textbox", { name: "Name" }).fill(repositoryName); await page.getByRole("combobox", { name: "Backend" }).click(); await page.getByRole("option", { name: "Local" }).click(); await page.getByRole("checkbox", { name: "Import existing repository" }).click(); if (customPassword) { await page.getByRole("combobox").filter({ hasText: "Use the existing recovery key" }).click(); await page.getByRole("option", { name: "Enter password manually" }).click(); await page.getByPlaceholder("Enter repository password").fill(customPassword); } await selectRepositoryDirectory(page, repositoryPath); await page.getByRole("button", { name: "Create repository" }).click(); await expect(page.getByText("Repository created successfully")).toBeVisible({ timeout: 30000 }); } async function openRepositorySnapshots(page: Page, repositoryName: string) { const repository = await getRepositoryByName(page, repositoryName); await gotoAndWaitForAppReady(page, `/repositories/${repository.shortId}`); await page.getByRole("tab", { name: "Snapshots" }).click(); await expect(page.getByText("Backup snapshots stored in this repository.")).toBeVisible(); } for (const importMode of importModes) { test(importMode.testName, async ({ page }, testInfo) => { const runId = getRunId(testInfo); const scenarioId = `${runId}-${importMode.shortName}`; const names = getScenarioNames(scenarioId); const importedRepositoryName = `Import-${scenarioId}`; const sourceRepositoryBaseName = `import-source-${runId}-${importMode.slug}`; const sourceRepositoryBasePath = `/test-data/${sourceRepositoryBaseName}`; const sourceRepositoryBaseHostPath = path.join(getWorkerTestDataPath(), sourceRepositoryBaseName); fs.mkdirSync(sourceRepositoryBaseHostPath, { recursive: true }); prepareTestFile(scenarioId, `import-${importMode.shortName}.json`); await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); await createBackupScenario(page, names, { repositoryBasePath: sourceRepositoryBasePath, selectedPaths: [`/${scenarioId}`], }); const sourceRepository = await getRepositoryByName(page, names.repositoryName); const sourceRepositoryDetail = await getRepositoryDetail(page, sourceRepository.shortId); const sourceRepositoryPath = getLocalRepositoryPath(sourceRepositoryDetail); expect(sourceRepositoryPath.startsWith(`${sourceRepositoryBasePath}/`)).toBe(true); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); const sourceSnapshots = await waitForRepositorySnapshots(page, sourceRepository.shortId, 1); const importedSnapshotShortId = sourceSnapshots[0].short_id; await deleteRepositoryConfig(page, sourceRepository.shortId); const customPassword = importMode.useCustomPassword ? await downloadResticPassword(page) : undefined; await importExistingLocalRepository(page, importedRepositoryName, sourceRepositoryPath, customPassword); const importedRepository = await getRepositoryByName(page, importedRepositoryName); const importedRepositoryDetail = await getRepositoryDetail(page, importedRepository.shortId); expect(importedRepositoryDetail.status).toBe("healthy"); expect(getLocalRepositoryPath(importedRepositoryDetail)).toBe(sourceRepositoryPath); const importedSnapshots = await waitForRepositorySnapshots(page, importedRepository.shortId, 1); expect(importedSnapshots[0].short_id).toBe(importedSnapshotShortId); await openRepositorySnapshots(page, importedRepositoryName); await expect(page.getByText(importedSnapshotShortId, { exact: true })).toBeVisible(); }); } test("can backup & restore a file", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); const filePath = prepareTestFile(runId); await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); await createBackupScenario(page, names); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" })); await page .getByRole("button", { name: /\d+(?:\.\d+)?\s(?:B|KiB|MiB|GiB|TiB)$/ }) .first() .click(); await page.getByRole("link", { name: "Restore" }).click(); await expect(page).toHaveURL(/\/restore/); await page.getByRole("button", { name: "Restore All" }).click(); await expect(page.getByText("Restore completed")).toBeVisible({ timeout: 30000 }); const restoredContent = fs.readFileSync(filePath, "utf8"); expect(JSON.parse(restoredContent)).toEqual({ data: "test file" }); }); 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(); const fileName = `single-file-${runId}.json`; const filePath = prepareTestFile(runId, fileName); const restoreTargetPath = path.join(workerTestDataPath, fileName); const escapedFileName = fileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); fs.rmSync(restoreTargetPath, { force: true }); await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); await createBackupScenario(page, names); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" })); await page .getByRole("button", { name: /\d+(?:\.\d+)?\s(?:B|KiB|MiB|GiB|TiB)$/ }) .first() .click(); await page.getByRole("link", { name: "Restore" }).click(); await expect(page).toHaveURL(/\/restore/); await page.getByRole("button", { name: "Custom location" }).click(); await page.getByRole("button", { name: "Change" }).click(); await page.getByRole("button", { name: /^test-data$/ }).click(); await expect(page.getByText("/test-data", { exact: true })).toBeVisible(); const runFolderRow = page.getByRole("button", { name: new RegExp(runId) }); await runFolderRow.locator("svg").first().click(); const fileRow = page.getByRole("button", { name: new RegExp(escapedFileName) }); await fileRow.getByRole("checkbox").click(); await expect(page.getByText("1 item selected")).toBeVisible(); await page.getByRole("button", { name: "Restore 1 item" }).click(); await expect(page.getByText("Restore completed")).toBeVisible({ timeout: 30000 }); const restoredContent = fs.readFileSync(restoreTargetPath, "utf8"); expect(JSON.parse(restoredContent)).toEqual({ data: "test file" }); fs.rmSync(restoreTargetPath, { force: true }); }); test("can re-tag a snapshot to another backup schedule", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); const secondBackupName = `${names.backupName}-retag`; prepareTestFile(runId, "retag.json"); await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); await createBackupScenario(page, names); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); await createBackupJob(page, { backupName: secondBackupName, volumeName: names.volumeName, repositoryName: names.repositoryName, }); await openRepositorySnapshots(page, names.repositoryName); await expect(page.getByRole("link", { name: names.backupName, exact: true })).toBeVisible(); await page .getByRole("checkbox", { name: /Select snapshot/ }) .first() .check(); await page.getByRole("button", { name: "Re-tag" }).click(); const retagSelect = page.getByRole("combobox"); const retagOption = page.getByRole("option", { name: secondBackupName, exact: true }); await expect(async () => { await retagSelect.click(); await expect(retagOption).toBeVisible(); }).toPass({ timeout: 10000 }); await retagOption.click(); await page.getByRole("button", { name: "Apply tags" }).click(); await expect(page.getByText(`Snapshots re-tagged to ${secondBackupName}`)).toBeVisible({ timeout: 30000 }); await expect(page.getByRole("link", { name: secondBackupName, exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: names.backupName, exact: true })).toHaveCount(0); }); test("can delete a snapshot from the repository snapshots tab", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); prepareTestFile(runId, "delete.json"); await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); await createBackupScenario(page, names); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); await openRepositorySnapshots(page, names.repositoryName); await expect(page.getByRole("checkbox", { name: /Select snapshot/ })).toHaveCount(1); await page .getByRole("checkbox", { name: /Select snapshot/ }) .first() .check(); await page.getByRole("button", { name: "Delete" }).click(); await expect(page.getByText("Delete 1 snapshots?")).toBeVisible(); await page.getByRole("button", { name: "Delete 1 snapshots" }).click(); await expect(page.getByText("Snapshots deleted successfully")).toBeVisible({ timeout: 30000 }); await expect(page.getByRole("checkbox", { name: /Select snapshot/ })).toHaveCount(0); await expect(page.getByRole("link", { name: names.backupName, exact: true })).toHaveCount(0); }); 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(); const fileName = `download-${runId}.json`; const filePath = prepareTestFile(runId, fileName); 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 page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" })); await page .getByRole("button", { name: /\d+(?:\.\d+)?\s(?:B|KiB|MiB|GiB|TiB)$/ }) .first() .click(); await page.getByRole("link", { name: "Restore" }).click(); await expect(page).toHaveURL(/\/restore/); const runFolderRow = page.getByRole("button", { name: new RegExp(runId) }); await runFolderRow.getByRole("checkbox").click(); await expect(page.getByText("1 item selected")).toBeVisible(); const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "Download 1 item" }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/^snapshot-.*\.tar$/); await download.saveAs(downloadedPath); const stats = fs.statSync(downloadedPath); expect(stats.size).toBeGreaterThan(0); fs.rmSync(downloadedPath, { force: true }); }); test("deleting a volume cascades and removes its backup schedule", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); await createBackupScenario(page, names); await gotoAndWaitForAppReady(page, "/backups"); await page.getByText(names.backupName, { exact: true }).first().click(); const volumeLink = page.locator("main").getByRole("link", { name: names.volumeName, exact: true }).first(); await expect(volumeLink).toBeVisible(); await volumeLink.click(); await expect(page).toHaveURL(/\/volumes\/[^/?#]+/); await expect(page.getByRole("button", { name: "Actions" })).toBeVisible(); await expect(async () => { await page.getByRole("button", { name: "Actions" }).click(); const deleteVolumeMenuItem = page.getByRole("menuitem", { name: "Delete" }); await expect(deleteVolumeMenuItem).toBeVisible(); await deleteVolumeMenuItem.click(); await expect(page.getByRole("heading", { name: "Delete volume?" })).toBeVisible(); }).toPass({ timeout: 10000 }); await expect( page.getByText("All backup schedules associated with this volume will also be removed."), ).toBeVisible(); await page.getByRole("button", { name: "Delete volume" }).click(); await expect(page.getByText("Volume deleted successfully")).toBeVisible(); await gotoAndWaitForAppReady(page, "/backups"); await expect(page.getByText(names.backupName, { exact: true })).toHaveCount(0); }); 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(); const keptDir = `kept-${runId}`; const secondKeptDir = `second-kept-${runId}`; const blockedDir = `blocked-${runId}`; const globOnlyDir = `glob-only-${runId}`; const dataDir = `data-${runId}`; const configDir = `config-${runId}`; const dataIncludedFile = `data-${runId}.txt`; const configIncludedFile = `config-${runId}.json`; const configExcludedFile = `secret-${runId}.json`; const configNonJsonFile = `config-${runId}.txt`; const rootDbFile = `root-${runId}.db`; const secondRootDbFile = `archive-${runId}.db`; const rootNonDbFile = `root-${runId}.txt`; 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 }); fs.mkdirSync(blockedPath, { recursive: true }); fs.mkdirSync(globOnlyPath, { recursive: true }); fs.mkdirSync(dataPath, { recursive: true }); fs.mkdirSync(configPath, { recursive: true }); fs.writeFileSync(path.join(keptPath, "keep.xyz"), "xyz content"); fs.writeFileSync(path.join(keptPath, ".DS_Store"), "excluded metadata"); fs.writeFileSync(path.join(keptPath, "skip.tmp"), "excluded tmp"); fs.writeFileSync(path.join(secondKeptPath, "second.xyz"), "xyz content"); fs.writeFileSync(path.join(secondKeptPath, ".DS_Store"), "excluded metadata"); fs.writeFileSync(path.join(blockedPath, ".nobackup"), "marker"); fs.writeFileSync(path.join(blockedPath, "blocked.xyz"), "should be excluded"); fs.writeFileSync(path.join(globOnlyPath, "glob-only.xyz"), "glob include"); fs.writeFileSync(path.join(dataPath, dataIncludedFile), "data include"); fs.writeFileSync(path.join(configPath, configIncludedFile), "json include"); 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(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, { includePatterns: [ `/${keptDir}`, `/${secondKeptDir}`, `/${blockedDir}`, `/${dataDir}/**`, `/${configDir}/*.json`, "*.db", "**/*.xyz", ].join("\n"), excludePatterns: [".DS_Store", "*.tmp", `/${configDir}/secret*.json`].join("\n"), excludeIfPresent: ".nobackup", }); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); await page .getByRole("button", { name: /\d+ B$/ }) .first() .click(); await expect(page.getByText("File Browser")).toBeVisible(); for (const folder of [keptDir, secondKeptDir, globOnlyDir, dataDir, configDir, blockedDir]) { const folderRow = page.getByRole("button", { name: folder, exact: true }); await expect(folderRow).toBeVisible(); await folderRow.locator("svg").first().click(); } await expect(page.getByRole("button", { name: /keep\.xyz/ })).toBeVisible({ timeout: 15000 }); await expect(page.getByRole("button", { name: /second\.xyz/ })).toBeVisible({ timeout: 15000 }); await expect(page.getByRole("button", { name: /glob-only\.xyz/ })).toBeVisible({ timeout: 15000 }); await expect(page.getByRole("button", { name: new RegExp(dataIncludedFile.replace(".", "\\.")) })).toBeVisible({ timeout: 15000, }); await expect(page.getByRole("button", { name: new RegExp(configIncludedFile.replace(".", "\\.")) })).toBeVisible({ timeout: 15000, }); await expect(page.getByRole("button", { name: new RegExp(rootDbFile.replace(".", "\\.")) })).toBeVisible({ timeout: 15000, }); await expect(page.getByRole("button", { name: new RegExp(secondRootDbFile.replace(".", "\\.")) })).toBeVisible({ timeout: 15000, }); await expect(page.getByRole("button", { name: /\.DS_Store/ })).toHaveCount(0); await expect(page.getByRole("button", { name: /skip\.tmp/ })).toHaveCount(0); await expect(page.getByRole("button", { name: new RegExp(configExcludedFile.replace(".", "\\.")) })).toHaveCount(0); await expect(page.getByRole("button", { name: new RegExp(configNonJsonFile.replace(".", "\\.")) })).toHaveCount(0); await expect(page.getByRole("button", { name: new RegExp(rootNonDbFile.replace(".", "\\.")) })).toHaveCount(0); await expect(page.getByRole("button", { name: /\.nobackup/ })).toBeVisible(); await expect(page.getByRole("button", { name: /blocked\.xyz/ })).toHaveCount(0); }); 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(); const bracketDir = `movies [${runId}]`; const bracketPath = path.join(workerTestDataPath, bracketDir); const fileName = `inside-${runId}.txt`; fs.mkdirSync(bracketPath, { recursive: true }); fs.writeFileSync(path.join(bracketPath, fileName), "bracket path content"); await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); await createBackupScenario(page, names, { selectedPaths: [`/${bracketDir}`], }); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); await page .getByRole("button", { name: /\d+ B$/ }) .first() .click(); const bracketFolderRow = page.getByRole("button", { name: new RegExp(bracketDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), }); await expect(bracketFolderRow).toBeVisible(); await bracketFolderRow.locator("svg").first().click(); await expect(page.getByRole("button", { name: new RegExp(fileName.replace(".", "\\.")) })).toBeVisible({ timeout: 15000, }); });