import { expect, Page, test } from "@playwright/test"; import fs from "fs/promises"; import path from "path"; const SERVER_URL = process.env.SERVER_URL || "http://localhost:8200/ngclient"; const HOME_URL = `${SERVER_URL}/`; const LOGIN_URL = `${SERVER_URL}/login`; const WEBSERVICE_PASSWORD = process.env.WEBSERVICE_PASSWORD || "easy1234"; const BACKUP_NAME = process.env.BACKUP_NAME || "PlaywrightBackup"; const PASSWORD = "the_backup_password_is_really_long_and_safe"; const SOURCE_FOLDER = path.resolve("playwright_source"); const DESTINATION_FOLDER = path.resolve("playwright_destination"); const RESTORE_FOLDER = path.resolve("playwright_restore"); const TEMP_FOLDER = path.resolve("playwright_temp"); const TESTFILE_NAME = "file.txt"; const CONFIG_FILE_PASSWORD = "another_strong_password"; const CONFIG_FILE_NAME = "duplicati-playwright-config.json.aes"; async function writeRandomFile(filepath: string, size: number) { await fs.mkdir(path.dirname(filepath), { recursive: true }); const buffer = Buffer.alloc(size); await fs.writeFile(filepath, buffer); } test.beforeAll(async () => { await fs.rm(SOURCE_FOLDER, { recursive: true, force: true }); await fs.rm(DESTINATION_FOLDER, { recursive: true, force: true }); await fs.rm(RESTORE_FOLDER, { recursive: true, force: true }); await fs.rm(TEMP_FOLDER, { recursive: true, force: true }); await writeRandomFile(path.join(SOURCE_FOLDER, TESTFILE_NAME), 1024); await fs.mkdir(TEMP_FOLDER, { recursive: true }); }); async function clickThreeDotMenu(page: Page, action: string) { const backupElement = page .locator("div.backup") .filter({ hasText: BACKUP_NAME }); await backupElement .locator("button") .filter({ has: page.locator("sh-icon").filter({ hasText: "three-vertical" }), }) .click(); await backupElement .locator("div.options button") .filter({ hasText: action }) .click(); } async function restoreAndVerify(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); await clickThreeDotMenu(page, "Restore"); await completeRestoreFlow(page); } async function completeRestoreFlow(page: Page) { await page.locator("div.text").filter({ hasText: TESTFILE_NAME }).click(); await page.locator("button").filter({ hasText: "Continue" }).click(); await page.locator("sh-radio").filter({ hasText: "Pick location" }).click(); await page .locator("button") .filter({ hasText: "Manually type path" }) .click(); await page.fill("[formcontrolname='restoreFromPath']", RESTORE_FOLDER); await page .locator("sh-radio") .filter({ hasText: "Save different versions", }) .click(); await page.locator("button").filter({ hasText: "Submit" }).click(); await page .locator("sh-card") .filter({ hasText: "Restore completed" }) .waitFor({ timeout: 60000 }); const restored = await fs.stat(path.join(RESTORE_FOLDER, "file.txt")); expect(restored.isFile()).toBeTruthy(); await fs.rm(path.join(RESTORE_FOLDER, "file.txt")); } async function createBackup(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); await page.locator("h2").filter({ hasText: "My backups" }).waitFor(); await page.click("text=Add backup"); await page.locator("button").filter({ hasText: "Add a new backup" }).click(); await page.fill("[formcontrolname='name']", BACKUP_NAME); await page.fill("[formcontrolname='password']", PASSWORD); await page.fill("[formcontrolname='repeatPassword']", PASSWORD); await page.locator("button").filter({ hasText: "Continue" }).click(); await page .locator( 'app-destination-list-item:has-text("File system") button:has-text("Choose")', ) .click(); await page .locator("button") .filter({ hasText: "Manually type path" }) .click(); await page.fill("#destination-custom-0-other", DESTINATION_FOLDER); await page.locator("button").filter({ hasText: "Test now" }).click(); await page .locator("footer") .filter({ has: page.locator("button").filter({ hasText: "Create folder" }), }) .locator("button") .filter({ hasText: "Create folder" }) .click(); await page.locator("button").filter({ hasText: "Continue" }).click(); await page .getByPlaceholder("Add a direct path") .fill(SOURCE_FOLDER + path.sep); await page .locator("app-toggle-card") .filter({ has: page.locator("h3").filter({ hasText: "Paths" }) }) .locator("button") .filter({ has: page.locator("sh-icon").filter({ hasText: "plus" }) }) .click(); await page.locator("button").filter({ hasText: "Continue" }).click(); // Select "Don't run automatically" from schedule await page.locator("sh-select").click(); await page .locator("li.option") .filter({ hasText: "Don't run automatically" }) .click(); await page.locator("button").filter({ hasText: "Continue" }).click(); await page.locator("button").filter({ hasText: "Submit" }).click(); } async function deleteBackupIfExists(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); try { const el = page.locator("div.backup, div.no-backups").first(); await el.waitFor({ timeout: 5000 }); const classAttr = (await el.getAttribute("class")) ?? ""; // No backups exist, just return if (classAttr.includes("no-backups")) { return; } } catch (e) {} await page.locator("div.backup").first().waitFor(); // Take a screenshot before waiting for backup elements await page.screenshot({ path: path.join("test-results", "before-backup-wait.png"), fullPage: true, }); // Log page content for debugging const pageContent = await page.content(); console.log("Page HTML length:", pageContent.length); console.log("Page title:", await page.title()); // Check if any backup elements exist const backupCount = await page.locator("div.backup").count(); console.log("Number of backup elements found:", backupCount); await page.locator("div.backup").first().waitFor(); // Cleanup existing backup with the same name const existingBackupElement = page .locator("div.backup") .filter({ hasText: BACKUP_NAME }); if ((await existingBackupElement.count()) > 0) { await clickThreeDotMenu(page, "Delete"); const deleteDatabase = page .locator("sh-checkbox") .filter({ hasText: "Delete local database" }) .locator('input[type="checkbox"]'); if (!(await deleteDatabase.isChecked())) { await deleteDatabase.click(); } await page.locator("button").filter({ hasText: "Delete backup" }).click(); await page.locator("text=Confirm delete").waitFor(); await page .locator("footer") .filter({ has: page.locator("button").filter({ hasText: "Delete backup" }), }) .locator("button") .filter({ hasText: "Delete backup" }) .click(); await existingBackupElement.waitFor({ state: "detached" }); } } async function runBackup(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); const chipLocator = page .locator("div.backup") .filter({ hasText: BACKUP_NAME }) .locator("sh-chip"); var currentText = await chipLocator.allInnerTexts(); const backupElement = page .locator("div.backup") .filter({ hasText: BACKUP_NAME }); await backupElement.locator("button").filter({ hasText: "Start" }).click(); // Wait for the chip to be present (assuming it updates after backup) await chipLocator.first().waitFor(); // Check that the text has changed const newText = await chipLocator.first().textContent(); expect(newText).not.toBe(currentText[0]); } async function directRestoreFromFiles(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); await page.click("text=Restore"); const restoreDirectCard = page.locator("sh-card").filter({ hasText: "Direct restore from backup files", }); restoreDirectCard.locator("button").filter({ hasText: "Start" }).click(); page .locator("app-destination-list-item") .filter({ has: page.locator("div.name").filter({ hasText: "File system" }), }) .locator("button") .filter({ hasText: "Choose", }) .click(); await page .locator("button") .filter({ hasText: "Manually type path" }) .click(); await page.fill("#destination-custom-0-other", DESTINATION_FOLDER); await page.locator("button").filter({ hasText: "Test now" }).click(); await page.locator("button").filter({ hasText: "Continue" }).click(); await page.fill("#password", PASSWORD); await page.locator("button").filter({ hasText: "Continue" }).click(); await completeRestoreFlow(page); } async function restoreFromConfigFile(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); await clickThreeDotMenu(page, "Export"); const exportPasswords = page .locator("sh-toggle") .filter({ hasText: "Export passwords" }) .locator('input[type="checkbox"]'); // Wait to ensure the UI is toggled properly await page.waitForTimeout(1000); if (!(await exportPasswords.isChecked())) { await exportPasswords.click(); } const encryptExportedFile = page .locator("sh-toggle") .filter({ hasText: "Encrypt file" }) .locator('input[type="checkbox"]'); if (!(await encryptExportedFile.isChecked())) { await encryptExportedFile.click(); } const downloadPromise = page.waitForEvent("download"); await page.fill("#password", CONFIG_FILE_PASSWORD); await page.fill("#repeatPassword", CONFIG_FILE_PASSWORD); await page.locator("button").filter({ hasText: "Export" }).click(); const download = await downloadPromise; const downloadPath = path.join(TEMP_FOLDER, CONFIG_FILE_NAME); await download.saveAs(downloadPath); console.log("Exported config file"); await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); await page.click("text=Restore"); const restoreConfigCard = page.locator("sh-card").filter({ hasText: "Restore from configuration", }); await restoreConfigCard .locator("button") .filter({ hasText: "Start" }) .click(); await page.setInputFiles( 'input[type="file"][accept=".json,.aes"]', downloadPath, ); await page.fill("[formcontrolname='passphrase']", CONFIG_FILE_PASSWORD); await page .locator("app-restore-from-config") .locator("button") .filter({ hasText: "Restore" }) .click(); console.log("Imported configuration, proceeding with restore..."); await completeRestoreFlow(page); } test("backup and restore flow", async ({ page }) => { // Enable console logging from the browser page.on("console", (msg) => console.log("Browser console:", msg.text())); page.on("pageerror", (err) => console.error("Browser error:", err.message)); await page .context() .addCookies([ { name: "default-client", value: "ngclient", url: SERVER_URL }, ]); await page.setDefaultTimeout(30000); await test.setTimeout(120000); console.log("Navigating to login page..."); await page.goto(LOGIN_URL); await page.waitForLoadState("networkidle"); // Take screenshot after login page loads await page.screenshot({ path: path.join("test-results", "01-login-page.png"), fullPage: true, }); await page.fill("[formcontrolname='pass']", WEBSERVICE_PASSWORD); await page.locator("button").filter({ hasText: "Login" }).click(); console.log("Waiting for page to load..."); // Take screenshot after login await page.screenshot({ path: path.join("test-results", "02-after-login.png"), fullPage: true, }); await page.locator("text=Add backup").waitFor(); // Take screenshot when home page is ready await page.screenshot({ path: path.join("test-results", "03-home-page-ready.png"), fullPage: true, }); // Ensure no existing backup console.log("Deleting existing backup if it exists..."); await deleteBackupIfExists(page); // Add backup console.log("Creating new backup..."); await createBackup(page); // Run backup console.log("Running backup..."); await runBackup(page); // Restore console.log("Restoring and verifying backup..."); await restoreAndVerify(page); // Restore directly from backup files console.log("Direct restore from backup files..."); await directRestoreFromFiles(page); // Restore from config console.log("Restore from configuration file..."); await restoreFromConfigFile(page); });