mirror of
https://github.com/duplicati/duplicati.git
synced 2025-11-28 11:30:24 +08:00
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
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 destination" }).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("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("div.tile")
|
|
.filter({
|
|
hasText: "File system",
|
|
})
|
|
.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 destination" }).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);
|
|
});
|