From a9954049eaa650a492786a5767176e717e334f83 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 13:43:00 +0100 Subject: [PATCH 01/19] Initial version of a PlayWright test using ngclient --- .github/workflows/tests.yml | 30 ++++- package-lock.json | 65 +++++++++ package.json | 6 +- playwright-tests/backupRestore.spec.ts | 177 +++++++++++++++++++++++++ playwright.config.ts | 11 ++ 5 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 playwright-tests/backupRestore.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f0d4b5ed..7ee79a69f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,10 +29,26 @@ jobs: - name: Run unit tests run: dotnet test --no-build --verbosity minimal Duplicati.sln - # Disabled, as a new test needs to be written for the new UI - # selenium: - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - name: Selenium - # run: pipeline/selenium/test.sh + playwright_tests: + name: Playwright UI tests + runs-on: ubuntu-latest + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: actions/checkout@v4 + - name: Install NPM dependencies + run: npm ci + - name: Install Playwright browsers + run: npx playwright install --with-deps + - name: Publish Duplicati server + run: dotnet publish -o published Duplicati.sln + - name: Start server + run: | + ./published/Duplicati.Server --webservice-password=easy1234 & + timeout 30 bash -c 'until printf "" 2>>/dev/null >>/dev/tcp/127.0.0.1/8200; do sleep 1; echo waiting; done' + - name: Run Playwright tests + run: npx playwright test diff --git a/package-lock.json b/package-lock.json index cecab55e6..a39661991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "devDependencies": { + "@playwright/test": "^1.43.1", "autoprefixer": "^10.4.20", "less": "^4.2.0", "less-plugin-clean-css": "^1.6.0", @@ -193,6 +194,23 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", + "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", + "deprecated": "Please update to the latest version of Playwright to test up-to-date browsers.", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.43.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", @@ -1545,6 +1563,53 @@ "node": ">=6" } }, + "node_modules/playwright": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.43.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", diff --git a/package.json b/package.json index 0261e4a9a..9de43368d 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,13 @@ "postcss": "^8.4.47", "postcss-cli": "^11.0.0", "stylelint": "^16.9.0", - "stylelint-config-standard-less": "^3.0.1" + "stylelint-config-standard-less": "^3.0.1", + "@playwright/test": "^1.43.1" }, "scripts": { "build:style": "npm run lint:style-fix; npx lessc Duplicati/Server/webroot/ngax/less/dark.less Duplicati/Server/webroot/ngax/styles/dark.css --clean-css -m=always && npx lessc Duplicati/Server/webroot/ngax/less/default.less Duplicati/Server/webroot/ngax/styles/default.css --clean-css -m=always && npx postcss Duplicati/Server/webroot/ngax/styles/dark.css Duplicati/Server/webroot/ngax/styles/default.css --no-map --use autoprefixer --replace", "lint:style": "npx stylelint \"Duplicati/Server/**/less/*.less\"", - "lint:style-fix": "npx stylelint \"Duplicati/Server/**/less/*.less\" --fix" + "lint:style-fix": "npx stylelint \"Duplicati/Server/**/less/*.less\" --fix", + "test:playwright": "playwright test" } } diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts new file mode 100644 index 000000000..63e850898 --- /dev/null +++ b/playwright-tests/backupRestore.spec.ts @@ -0,0 +1,177 @@ +import { expect, test } from "@playwright/test"; +import fs from "fs/promises"; +import path from "path"; + +const SERVER_URL = process.env.SERVER_URL || "http://localhost:8200"; +const SPA_PATH = "/ngclient"; +const HOME_URL = `${SERVER_URL}${SPA_PATH}/index.html`; +const LOGIN_URL = `${SERVER_URL}/login.html`; +const WEBSERVICE_PASSWORD = "1234"; +const 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 TESTFILE_NAME = "file.txt"; + +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 writeRandomFile(path.join(SOURCE_FOLDER, TESTFILE_NAME), 1024); + await fs.mkdir(DESTINATION_FOLDER, { recursive: true }); +}); + +test("backup and restore flow", async ({ page }) => { + await page + .context() + .addCookies([ + { name: "default-client", value: "ngclient", url: SERVER_URL }, + ]); + await page.goto(LOGIN_URL); + await page.fill("#login-password", WEBSERVICE_PASSWORD); + await page.click("#login-button"); + + await page.waitForURL(HOME_URL); + await page.waitForLoadState("networkidle"); + await page.locator("div.backup").first().waitFor(); + + // Cleanup existing backup with the same name + const existingBackupElement = page + .locator("div.backup") + .filter({ hasText: "PlaywrightBackup" }); + + if ((await existingBackupElement.count()) > 0) { + await existingBackupElement + .locator("button") + .filter({ + has: page.locator("sh-icon").filter({ hasText: "three-vertical" }), + }) + .click(); + + await page + .locator("div.options button") + .filter({ hasText: "Delete" }) + .click(); + + 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" }); + } + + // Add backup + 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("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(); + + const useScheduleRun = page + .locator("sh-toggle") + .filter({ hasText: "Automatically run backups" }) + .locator('input[type="checkbox"]'); + + if (await useScheduleRun.isChecked()) { + await useScheduleRun.click(); + } + await page.locator("button").filter({ hasText: "Continue" }).click(); + await page.locator("button").filter({ hasText: "Submit" }).click(); + + // Run backup + const backupElement = page + .locator("div.backup") + .filter({ hasText: "PlayWrightBackup" }); + await backupElement.locator("button").filter({ hasText: "Start" }).click(); + + await page + .locator("div.backup") + .filter({ hasText: "PlaywrightBackup" }) + .locator("sh-chip") + .filter({ hasText: "1 Version" }) + .waitFor(); + + // Restore + backupElement + .locator("button") + .filter({ + has: page.locator("sh-icon").filter({ hasText: "three-vertical" }), + }) + .click(); + + await page + .locator("div.options button") + .filter({ hasText: "Restore" }) + .click(); + + 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(); + + const restored = await fs.stat(path.join(RESTORE_FOLDER, "file.txt")); + expect(restored.isFile()).toBeTruthy(); +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..da9d1567a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + use: { + baseURL: 'http://localhost:8200', + headless: true, + }, + testDir: 'playwright-tests', + timeout: 120000, + workers: 1, +}); From ad606644cbb81bb152dd97c3b91b625fc14e6ed9 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 13:43:54 +0100 Subject: [PATCH 02/19] Fixed correct password --- playwright-tests/backupRestore.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index 63e850898..d6179f962 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -6,7 +6,7 @@ const SERVER_URL = process.env.SERVER_URL || "http://localhost:8200"; const SPA_PATH = "/ngclient"; const HOME_URL = `${SERVER_URL}${SPA_PATH}/index.html`; const LOGIN_URL = `${SERVER_URL}/login.html`; -const WEBSERVICE_PASSWORD = "1234"; +const WEBSERVICE_PASSWORD = "easy1234"; const BACKUP_NAME = "PlaywrightBackup"; const PASSWORD = "the_backup_password_is_really_long_and_safe"; const SOURCE_FOLDER = path.resolve("playwright_source"); From fd9b033a0864ba0a04be281a0e06d78c6675bd93 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 13:49:16 +0100 Subject: [PATCH 03/19] Potential fix for code scanning alert no. 315: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7ee79a69f..e6c368b8b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,6 @@ name: Tests +permissions: + contents: read on: [pull_request, workflow_dispatch] From 92951bd0eafc1aefe8f62dd7128e67276d96a9f5 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 19:56:44 +0100 Subject: [PATCH 04/19] Updated test to include direct restore and restore from config --- playwright-tests/backupRestore.spec.ts | 322 +++++++++++++++++++------ 1 file changed, 248 insertions(+), 74 deletions(-) diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index d6179f962..bf2d8dc51 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -1,18 +1,21 @@ -import { expect, test } from "@playwright/test"; +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"; const SPA_PATH = "/ngclient"; -const HOME_URL = `${SERVER_URL}${SPA_PATH}/index.html`; -const LOGIN_URL = `${SERVER_URL}/login.html`; +const HOME_URL = `${SERVER_URL}${SPA_PATH}/`; +const LOGIN_URL = `${SERVER_URL}${SPA_PATH}/login`; const WEBSERVICE_PASSWORD = "easy1234"; const 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 }); @@ -24,21 +27,123 @@ 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 }); + // Remove this line once "Test destination" button works reliably await fs.mkdir(DESTINATION_FOLDER, { recursive: true }); }); -test("backup and restore flow", async ({ page }) => { - await page - .context() - .addCookies([ - { name: "default-client", value: "ngclient", url: SERVER_URL }, - ]); - await page.goto(LOGIN_URL); - await page.fill("#login-password", WEBSERVICE_PASSWORD); - await page.click("#login-button"); +async function restoreAndVerify(page: Page) { + await page.goto(HOME_URL); + await page.waitForLoadState("networkidle"); - await page.waitForURL(HOME_URL); + const backupElement = page + .locator("div.backup") + .filter({ hasText: "PlayWrightBackup" }); + + backupElement + .locator("button") + .filter({ + has: page.locator("sh-icon").filter({ hasText: "three-vertical" }), + }) + .click(); + + await page + .locator("div.options button") + .filter({ hasText: "Restore" }) + .click(); + + 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(); + + 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("div.backup").first().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(); + + // Comment in this once the "Test connection" button works reliably + // 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(); + + const useScheduleRun = page + .locator("sh-toggle") + .filter({ hasText: "Automatically run backups" }) + .locator('input[type="checkbox"]'); + + if (await useScheduleRun.isChecked()) { + await useScheduleRun.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"); await page.locator("div.backup").first().waitFor(); @@ -82,19 +187,50 @@ test("backup and restore flow", async ({ page }) => { await existingBackupElement.waitFor({ state: "detached" }); } +} - // Add backup - 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")' - ) +async function runBackup(page: Page) { + await page.goto(HOME_URL); + await page.waitForLoadState("networkidle"); + + const chipLocator = page + .locator("div.backup") + .filter({ hasText: "PlaywrightBackup" }) + .locator("sh-chip"); + + var currentText = await chipLocator.allInnerTexts(); + + const backupElement = page + .locator("div.backup") + .filter({ hasText: "PlayWrightBackup" }); + 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" }) @@ -102,41 +238,18 @@ test("backup and restore flow", async ({ page }) => { 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 - .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.fill("#password", PASSWORD); await page.locator("button").filter({ hasText: "Continue" }).click(); - const useScheduleRun = page - .locator("sh-toggle") - .filter({ hasText: "Automatically run backups" }) - .locator('input[type="checkbox"]'); - - if (await useScheduleRun.isChecked()) { - await useScheduleRun.click(); - } - await page.locator("button").filter({ hasText: "Continue" }).click(); - await page.locator("button").filter({ hasText: "Submit" }).click(); - - // Run backup - const backupElement = page - .locator("div.backup") - .filter({ hasText: "PlayWrightBackup" }); - await backupElement.locator("button").filter({ hasText: "Start" }).click(); + await completeRestoreFlow(page); +} +async function restoreFromConfigFile(page: Page) { + await page.goto(HOME_URL); + await page.waitForLoadState("networkidle"); await page .locator("div.backup") - .filter({ hasText: "PlaywrightBackup" }) - .locator("sh-chip") - .filter({ hasText: "1 Version" }) - .waitFor(); - - // Restore - backupElement + .filter({ hasText: "PlayWrightBackup" }) .locator("button") .filter({ has: page.locator("sh-icon").filter({ hasText: "three-vertical" }), @@ -145,33 +258,94 @@ test("backup and restore flow", async ({ page }) => { await page .locator("div.options button") + .filter({ hasText: "Export" }) + .click(); + + const exportPasswords = page + .locator("sh-toggle") + .filter({ hasText: "Export passwords" }) + .locator('input[type="checkbox"]'); + + 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); + + 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(); - await page.locator("div.text").filter({ hasText: TESTFILE_NAME }).click(); - await page.locator("button").filter({ hasText: "Continue" }).click(); + await completeRestoreFlow(page); +} - await page.locator("sh-radio").filter({ hasText: "Pick location" }).click(); +test("backup and restore flow", async ({ page }) => { await page - .locator("button") - .filter({ hasText: "Manually type path" }) - .click(); - await page.fill("[formcontrolname='restoreFromPath']", RESTORE_FOLDER); + .context() + .addCookies([ + { name: "default-client", value: "ngclient", url: SERVER_URL }, + ]); + await page.goto(LOGIN_URL); + await page.waitForLoadState("networkidle"); + await page.fill("[formcontrolname='pass']", WEBSERVICE_PASSWORD); - await page - .locator("sh-radio") - .filter({ - hasText: "Save different versions", - }) - .click(); + await page.locator("button").filter({ hasText: "Login" }).click(); - await page.locator("button").filter({ hasText: "Submit" }).click(); + await page.waitForURL(HOME_URL); - await page - .locator("sh-card") - .filter({ hasText: "Restore completed" }) - .waitFor(); + // Ensure no existing backup + await deleteBackupIfExists(page); - const restored = await fs.stat(path.join(RESTORE_FOLDER, "file.txt")); - expect(restored.isFile()).toBeTruthy(); + // Add backup + await createBackup(page); + + // Run backup + await runBackup(page); + + // Restore + await restoreAndVerify(page); + + // Restore directly from backup files + await directRestoreFromFiles(page); + + // Restore from config + await restoreFromConfigFile(page); }); From fe6fee3bbfe130d1674fba09e1f3cebd4582ba06 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 20:04:33 +0100 Subject: [PATCH 05/19] Updated playwright version --- package-lock.json | 31 +++++++++++++++---------------- package.json | 2 +- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index a39661991..8abc5bf6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "devDependencies": { - "@playwright/test": "^1.43.1", + "@playwright/test": "^1.48.0", "autoprefixer": "^10.4.20", "less": "^4.2.0", "less-plugin-clean-css": "^1.6.0", @@ -195,20 +195,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", - "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", - "deprecated": "Please update to the latest version of Playwright to test up-to-date browsers.", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.43.1" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/@sindresorhus/merge-streams": { @@ -1564,35 +1563,35 @@ } }, "node_modules/playwright": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", - "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.43.1" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", - "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/playwright/node_modules/fsevents": { diff --git a/package.json b/package.json index 9de43368d..061ed0af7 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "postcss-cli": "^11.0.0", "stylelint": "^16.9.0", "stylelint-config-standard-less": "^3.0.1", - "@playwright/test": "^1.43.1" + "@playwright/test": "^1.48.0" }, "scripts": { "build:style": "npm run lint:style-fix; npx lessc Duplicati/Server/webroot/ngax/less/dark.less Duplicati/Server/webroot/ngax/styles/dark.css --clean-css -m=always && npx lessc Duplicati/Server/webroot/ngax/less/default.less Duplicati/Server/webroot/ngax/styles/default.css --clean-css -m=always && npx postcss Duplicati/Server/webroot/ngax/styles/dark.css Duplicati/Server/webroot/ngax/styles/default.css --no-map --use autoprefixer --replace", From 630920915048f7229ceed9fd3f1db94e8feb0c18 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 20:16:43 +0100 Subject: [PATCH 06/19] Set default timeout --- playwright-tests/backupRestore.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index bf2d8dc51..ad4cab1ec 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -323,6 +323,7 @@ test("backup and restore flow", async ({ page }) => { .addCookies([ { name: "default-client", value: "ngclient", url: SERVER_URL }, ]); + await page.setDefaultTimeout(30000); await page.goto(LOGIN_URL); await page.waitForLoadState("networkidle"); await page.fill("[formcontrolname='pass']", WEBSERVICE_PASSWORD); From 167524f29268a59da53111e75d0a5c7f5eaf215d Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 20:36:16 +0100 Subject: [PATCH 07/19] Style fixes --- .github/workflows/tests.yml | 2 +- playwright.config.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e6c368b8b..1678854c8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,7 +50,7 @@ jobs: run: dotnet publish -o published Duplicati.sln - name: Start server run: | - ./published/Duplicati.Server --webservice-password=easy1234 & + ./published/Duplicati.Server --disable-database-encryption --webservice-password=easy1234 & timeout 30 bash -c 'until printf "" 2>>/dev/null >>/dev/tcp/127.0.0.1/8200; do sleep 1; echo waiting; done' - name: Run Playwright tests run: npx playwright test diff --git a/playwright.config.ts b/playwright.config.ts index da9d1567a..da06f7eeb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,11 +1,11 @@ -import { defineConfig } from '@playwright/test'; +import { defineConfig } from "@playwright/test"; export default defineConfig({ use: { - baseURL: 'http://localhost:8200', + baseURL: "http://localhost:8200", headless: true, }, - testDir: 'playwright-tests', + testDir: "playwright-tests", timeout: 120000, workers: 1, }); From c7416408f1d17406690869b8ca02570639951937 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 20:45:05 +0100 Subject: [PATCH 08/19] Added support for env-based input Added script to run tests --- package-lock.json | 18 ++++++++++++++++++ package.json | 5 +++-- playwright-tests/backupRestore.spec.ts | 14 +++++++------- playwright-tests/run-tests-headed.sh | 2 ++ 4 files changed, 30 insertions(+), 9 deletions(-) create mode 100755 playwright-tests/run-tests-headed.sh diff --git a/package-lock.json b/package-lock.json index 8abc5bf6b..ece29ab50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "devDependencies": { "@playwright/test": "^1.48.0", + "@types/node": "^24.10.0", "autoprefixer": "^10.4.20", "less": "^4.2.0", "less-plugin-clean-css": "^1.6.0", @@ -223,6 +224,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -2450,6 +2461,13 @@ "dev": true, "license": "0BSD" }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", diff --git a/package.json b/package.json index 061ed0af7..83eefe3d1 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^24.10.0", "autoprefixer": "^10.4.20", "less": "^4.2.0", "less-plugin-clean-css": "^1.6.0", "postcss": "^8.4.47", "postcss-cli": "^11.0.0", "stylelint": "^16.9.0", - "stylelint-config-standard-less": "^3.0.1", - "@playwright/test": "^1.48.0" + "stylelint-config-standard-less": "^3.0.1" }, "scripts": { "build:style": "npm run lint:style-fix; npx lessc Duplicati/Server/webroot/ngax/less/dark.less Duplicati/Server/webroot/ngax/styles/dark.css --clean-css -m=always && npx lessc Duplicati/Server/webroot/ngax/less/default.less Duplicati/Server/webroot/ngax/styles/default.css --clean-css -m=always && npx postcss Duplicati/Server/webroot/ngax/styles/dark.css Duplicati/Server/webroot/ngax/styles/default.css --no-map --use autoprefixer --replace", diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index ad4cab1ec..f9859d2be 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -6,8 +6,8 @@ const SERVER_URL = process.env.SERVER_URL || "http://localhost:8200"; const SPA_PATH = "/ngclient"; const HOME_URL = `${SERVER_URL}${SPA_PATH}/`; const LOGIN_URL = `${SERVER_URL}${SPA_PATH}/login`; -const WEBSERVICE_PASSWORD = "easy1234"; -const BACKUP_NAME = "PlaywrightBackup"; +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"); @@ -40,7 +40,7 @@ async function restoreAndVerify(page: Page) { const backupElement = page .locator("div.backup") - .filter({ hasText: "PlayWrightBackup" }); + .filter({ hasText: BACKUP_NAME }); backupElement .locator("button") @@ -150,7 +150,7 @@ async function deleteBackupIfExists(page: Page) { // Cleanup existing backup with the same name const existingBackupElement = page .locator("div.backup") - .filter({ hasText: "PlaywrightBackup" }); + .filter({ hasText: BACKUP_NAME }); if ((await existingBackupElement.count()) > 0) { await existingBackupElement @@ -195,14 +195,14 @@ async function runBackup(page: Page) { const chipLocator = page .locator("div.backup") - .filter({ hasText: "PlaywrightBackup" }) + .filter({ hasText: BACKUP_NAME }) .locator("sh-chip"); var currentText = await chipLocator.allInnerTexts(); const backupElement = page .locator("div.backup") - .filter({ hasText: "PlayWrightBackup" }); + .filter({ hasText: BACKUP_NAME }); await backupElement.locator("button").filter({ hasText: "Start" }).click(); // Wait for the chip to be present (assuming it updates after backup) @@ -249,7 +249,7 @@ async function restoreFromConfigFile(page: Page) { await page.waitForLoadState("networkidle"); await page .locator("div.backup") - .filter({ hasText: "PlayWrightBackup" }) + .filter({ hasText: BACKUP_NAME }) .locator("button") .filter({ has: page.locator("sh-icon").filter({ hasText: "three-vertical" }), diff --git a/playwright-tests/run-tests-headed.sh b/playwright-tests/run-tests-headed.sh new file mode 100755 index 000000000..2d5583d91 --- /dev/null +++ b/playwright-tests/run-tests-headed.sh @@ -0,0 +1,2 @@ +#!/bin/bash +WEBSERVICE_PASSWORD=easy1234 npx playwright test --headed \ No newline at end of file From 9d44e519b2628a3c74f0a2ffdf8fd0c827510afd Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 20:49:28 +0100 Subject: [PATCH 09/19] Wait a bit longer for restores to complete --- playwright-tests/backupRestore.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index f9859d2be..c2746d5e1 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -80,7 +80,7 @@ async function completeRestoreFlow(page: Page) { await page .locator("sh-card") .filter({ hasText: "Restore completed" }) - .waitFor(); + .waitFor({ timeout: 60000 }); const restored = await fs.stat(path.join(RESTORE_FOLDER, "file.txt")); expect(restored.isFile()).toBeTruthy(); From 18b8c575f3c11f824f2db0d67db6deb44e518c3c Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 21:21:35 +0100 Subject: [PATCH 10/19] Improved tests a bit further --- playwright-tests/backupRestore.spec.ts | 61 +++++++++++++------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index c2746d5e1..4ae5c6ce2 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -34,26 +34,29 @@ test.beforeAll(async () => { await fs.mkdir(DESTINATION_FOLDER, { recursive: true }); }); -async function restoreAndVerify(page: Page) { - await page.goto(HOME_URL); - await page.waitForLoadState("networkidle"); - +async function clickThreeDotMenu(page: Page, action: string) { const backupElement = page .locator("div.backup") .filter({ hasText: BACKUP_NAME }); - backupElement + await backupElement .locator("button") .filter({ has: page.locator("sh-icon").filter({ hasText: "three-vertical" }), }) .click(); - await page + await backupElement .locator("div.options button") - .filter({ hasText: "Restore" }) + .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); } @@ -153,17 +156,7 @@ async function deleteBackupIfExists(page: Page) { .filter({ hasText: BACKUP_NAME }); if ((await existingBackupElement.count()) > 0) { - await existingBackupElement - .locator("button") - .filter({ - has: page.locator("sh-icon").filter({ hasText: "three-vertical" }), - }) - .click(); - - await page - .locator("div.options button") - .filter({ hasText: "Delete" }) - .click(); + await clickThreeDotMenu(page, "Delete"); const deleteDatabase = page .locator("sh-checkbox") @@ -247,25 +240,17 @@ async function directRestoreFromFiles(page: Page) { async function restoreFromConfigFile(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); - await page - .locator("div.backup") - .filter({ hasText: BACKUP_NAME }) - .locator("button") - .filter({ - has: page.locator("sh-icon").filter({ hasText: "three-vertical" }), - }) - .click(); - await page - .locator("div.options button") - .filter({ hasText: "Export" }) - .click(); + 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(); } @@ -288,6 +273,8 @@ async function restoreFromConfigFile(page: Page) { const downloadPath = path.join(TEMP_FOLDER, CONFIG_FILE_NAME); await download.saveAs(downloadPath); + console.log("Saved config file to: ", downloadPath); + await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); await page.click("text=Restore"); @@ -314,6 +301,8 @@ async function restoreFromConfigFile(page: Page) { .filter({ hasText: "Restore" }) .click(); + console.log("Imported configuration, proceeding with restore..."); + await completeRestoreFlow(page); } @@ -324,29 +313,39 @@ test("backup and restore flow", async ({ page }) => { { 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"); await page.fill("[formcontrolname='pass']", WEBSERVICE_PASSWORD); await page.locator("button").filter({ hasText: "Login" }).click(); - await page.waitForURL(HOME_URL); + console.log("Waiting for page to load..."); + await page.locator("text=Add backup").waitFor(); // 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); }); From b294d378e682a5844fc4508b7914ac4e4eaeaf11 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 21:26:06 +0100 Subject: [PATCH 11/19] Simplified testing setup a bit for external UI --- playwright-tests/backupRestore.spec.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index 4ae5c6ce2..549e9f17c 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -2,10 +2,9 @@ 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"; -const SPA_PATH = "/ngclient"; -const HOME_URL = `${SERVER_URL}${SPA_PATH}/`; -const LOGIN_URL = `${SERVER_URL}${SPA_PATH}/login`; +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"; @@ -273,7 +272,7 @@ async function restoreFromConfigFile(page: Page) { const downloadPath = path.join(TEMP_FOLDER, CONFIG_FILE_NAME); await download.saveAs(downloadPath); - console.log("Saved config file to: ", downloadPath); + console.log("Exported config file"); await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); From 236f374a6cc7cc89a2e842b6e4101569d8a4c5ac Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 21:35:15 +0100 Subject: [PATCH 12/19] Log failures --- .github/workflows/tests.yml | 8 ++++++++ playwright.config.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1678854c8..f1eadc165 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,3 +54,11 @@ jobs: timeout 30 bash -c 'until printf "" 2>>/dev/null >>/dev/tcp/127.0.0.1/8200; do sleep 1; echo waiting; done' - name: Run Playwright tests run: npx playwright test + + - name: Upload Playwright test results on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: test-results/ + retention-days: 7 diff --git a/playwright.config.ts b/playwright.config.ts index da06f7eeb..20a06dc1f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,4 +8,6 @@ export default defineConfig({ testDir: "playwright-tests", timeout: 120000, workers: 1, + reporter: process.env.CI ? "html" : "list", + outputDir: "test-results/", }); From 79b70b89b4afb08cf59d2d7a5944e27680b0a315 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 21:36:39 +0100 Subject: [PATCH 13/19] Preload ngclient before testing --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f1eadc165..a940ef785 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,6 +52,8 @@ jobs: run: | ./published/Duplicati.Server --disable-database-encryption --webservice-password=easy1234 & timeout 30 bash -c 'until printf "" 2>>/dev/null >>/dev/tcp/127.0.0.1/8200; do sleep 1; echo waiting; done' + - name: Load web UI + run: curl -f http://localhost:8200/ngclient/index.html - name: Run Playwright tests run: npx playwright test From 193c095f9e73ee09cf56c951499a00f03717726b Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 21:48:06 +0100 Subject: [PATCH 14/19] Ensure ngclient is loaded --- .github/workflows/tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f7b155b2..34a80369b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,6 +74,10 @@ jobs: run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps + - name: Install web UI dependencies + run: | + cd Duplicati/Server/webroot/ngclient + npm ci - name: Publish Duplicati server run: dotnet publish -o published Duplicati.sln - name: Start server From c011fa8b841c89de582497edcbd6ef89328e4e3b Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 21:52:07 +0100 Subject: [PATCH 15/19] Updated tests to check that "create folder" dialog shows --- playwright-tests/backupRestore.spec.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index 549e9f17c..5a57cb283 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -29,8 +29,6 @@ test.beforeAll(async () => { 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 }); - // Remove this line once "Test destination" button works reliably - await fs.mkdir(DESTINATION_FOLDER, { recursive: true }); }); async function clickThreeDotMenu(page: Page, action: string) { @@ -112,15 +110,14 @@ async function createBackup(page: Page) { await page.fill("#destination-custom-0-other", DESTINATION_FOLDER); await page.locator("button").filter({ hasText: "Test destination" }).click(); - // Comment in this once the "Test connection" button works reliably - // await page - // .locator("footer") - // .filter({ - // has: page.locator("button").filter({ hasText: "Create folder" }), - // }) - // .locator("button") - // .filter({ hasText: "Create folder" }) - // .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 From 67bb15624377c647655fae345b77e78e62b322a8 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 4 Nov 2025 22:19:17 +0100 Subject: [PATCH 16/19] Build in debug mode to preserve auto-load features --- .github/workflows/tests.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 34a80369b..58c01a593 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,12 +74,8 @@ jobs: run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps - - name: Install web UI dependencies - run: | - cd Duplicati/Server/webroot/ngclient - npm ci - name: Publish Duplicati server - run: dotnet publish -o published Duplicati.sln + run: dotnet publish -c Debug -o published Duplicati.sln - name: Start server run: | ./published/Duplicati.Server --disable-database-encryption --webservice-password=easy1234 & From 9a542258a519889b6eea6d27d65905bce81086c1 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 6 Nov 2025 09:33:43 +0100 Subject: [PATCH 17/19] Removed old selenium tests --- guiTests/guiTest.py | 320 -------------------------------------------- 1 file changed, 320 deletions(-) delete mode 100644 guiTests/guiTest.py diff --git a/guiTests/guiTest.py b/guiTests/guiTest.py deleted file mode 100644 index 62bd6d287..000000000 --- a/guiTests/guiTest.py +++ /dev/null @@ -1,320 +0,0 @@ -import argparse -import os -import sys -import shutil -import errno -import time -import hashlib -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions - -parser = argparse.ArgumentParser() -parser.add_argument( - "--headless", action='store_true' -) -parser.add_argument( - "--no-headless", dest='headless', action='store_false' -) -parser.add_argument( - "--use-chrome", action='store_true' -) -parser.add_argument( - "--chrome-path" -) - -parser.set_defaults(headless=True) -cmdopt = parser.parse_args() - -if "TRAVIS_BUILD_NUMBER" in os.environ: - from selenium.webdriver.firefox.options import Options - if "SAUCE_USERNAME" not in os.environ: - print("No sauce labs login credentials found. Stopping tests...") - sys.exit(0) - - capabilities = {'browserName': "firefox"} - capabilities['platform'] = "Windows 7" - capabilities['version'] = "48.0" - capabilities['screenResolution'] = "1280x1024" - capabilities["build"] = os.environ["TRAVIS_BUILD_NUMBER"] - capabilities["tunnel-identifier"] = os.environ["TRAVIS_JOB_NUMBER"] - - # connect to sauce labs - username = os.environ["SAUCE_USERNAME"] - access_key = os.environ["SAUCE_ACCESS_KEY"] - hub_url = "%s:%s@localhost:4445" % (username, access_key) - driver = webdriver.Remote(command_executor="http://%s/wd/hub" % hub_url, desired_capabilities=capabilities) -elif cmdopt.use_chrome: - print("using LOCAL Chrome webdriver") - from selenium.webdriver.chrome.options import Options - chr_opt = Options() - - if cmdopt.chrome_path is None: - import chromedriver_autoinstaller - chromedriver_autoinstaller.install() - else: - chr_opt.binary_location = cmdopt.chrome_path - - opt = ["--ignore-certificate-errors", "--window-size=1280,800" ] - if cmdopt.headless: opt += ["--headless"] - for o in opt: chr_opt.add_argument(o) - chr_opt.set_capability('goog:loggingPrefs', { 'browser':'ALL' }) - driver = webdriver.Chrome(options=chr_opt) -else: - from selenium.webdriver.firefox.options import Options - print("Using LOCAL Firefox webdriver") - options = Options() - options.set_preference("intl.accept_languages", "en") - options.headless = cmdopt.headless - driver = webdriver.Firefox(options=options) - -def write_random_file(size, filename): - if not os.path.exists(os.path.dirname(filename)): - try: - os.makedirs(os.path.dirname(filename)) - except OSError as exc: # Guard against race condition - if exc.errno != errno.EEXIST: - raise - - with open(filename, 'wb') as fout: - fout.write(os.urandom(size)) - - -def sha1_file(filename): - BLOCKSIZE = 65536 - hasher = hashlib.sha1() - with open(filename, 'rb') as afile: - buf = afile.read(BLOCKSIZE) - while len(buf) > 0: - hasher.update(buf) - buf = afile.read(BLOCKSIZE) - - return hasher.hexdigest() - - -def sha1_folder(folder): - sha1_dict = {} - for root, dirs, files in os.walk(folder): - for filename in files: - file_path = os.path.join(root, filename) - sha1 = sha1_file(file_path) - relative_file_path = os.path.relpath(file_path, folder) - sha1_dict.update({relative_file_path: sha1}) - - return sha1_dict - - -def wait_for_text(xpath, text, timeout=10): - WebDriverWait(driver, timeout).until(expected_conditions.text_to_be_present_in_element((By.XPATH, xpath), text)) - -def wait_for_load(by, target, timeout=10): - return WebDriverWait(driver, timeout).until(expected_conditions.presence_of_element_located((by, target))) - -def wait_for_clickable(by, target, timeout=10): - WebDriverWait(driver, timeout).until(expected_conditions.presence_of_element_located((by, target))) - return WebDriverWait(driver, timeout).until(expected_conditions.element_to_be_clickable((by, target))) - -def wait_for_redirect(expected_url, timeout=10): - WebDriverWait(driver, timeout).until(lambda driver: driver.current_url == expected_url) - -def wait_for_title(title, timeout=10): - WebDriverWait(driver, timeout).until(lambda driver: title in driver.title) - -def runTests(): - HOME_URL = "http://localhost:8200/ngax/index.html" - LOGIN_URL = "http://localhost:8200/login.html" - PRELOAD_URLS = [ - "http://localhost:8200/ngax/index.html#/addstart", - "http://localhost:8200/ngax/index.html#/add", - "http://localhost:8200/ngax/index.html#/restorestart" - "http://localhost:8200/ngax/index.html#/restoredirect" - "http://localhost:8200/ngax/index.html#/" - ] - WEBSERVICE_PASSWORD = "easy1234" - BACKUP_NAME = "BackupName" - PASSWORD = "the_backup_password_is_really_long_and_safe" - SOURCE_FOLDER = os.path.abspath("duplicati_gui_test_source") - DESTINATION_FOLDER = os.path.abspath("duplicati_gui_test_destination") - DESTINATION_FOLDER_DIRECT_RESTORE = os.path.abspath("duplicati_gui_test_destination_direct_restore") - RESTORE_FOLDER = os.path.abspath("duplicati_gui_test_restore") - DIRECT_RESTORE_FOLDER = os.path.abspath("duplicati_gui_test_direct_restore") - - driver.maximize_window() - driver.get(LOGIN_URL) - wait_for_load(By.ID, "login-password").send_keys(WEBSERVICE_PASSWORD) - wait_for_load(By.ID, "login-button").click() - - print("Initial page loading ...") - wait_for_redirect(HOME_URL) - - print("Preloading pages ...") - for url in PRELOAD_URLS: - driver.get(url) - time.sleep(1) - - driver.get(HOME_URL) - time.sleep(1) - - # Load attempts - attempts = 3 - - # When running in headless mode the requests are too fast - # and index.html loads multiple .js files which exhaust the - # Chrome pending request queue (but only in headless mode) - # So we re-issue the "get" to depend on cached results - # meaning less requests and less chance of exhausting the queue - # Upgrading to a newer Angular version will fix this issue - # - # After the initial load is complete, caching will ensure - # that only a few files are loaded - while attempts > 0: - try: - attempts -= 1 - wait_for_title("Duplicati") - wait_for_clickable(By.LINK_TEXT, "Add backup") - if driver.find_element(By.ID, "connection-lost-dialog").is_displayed(): - raise Exception("connection-lost-dialog is displayed") - - print("Loaded page, assuming all resources are now ready") - break - except: - print("Loading failed, retrying") - driver.get(HOME_URL) - time.sleep(1) - - # Wait for all resources to load - time.sleep(2) - - print("Browser log lines before test: ") - for entry in driver.get_log('browser'): - print(entry) - - # Create and hash random files in the source folder - write_random_file(1024 * 1024, SOURCE_FOLDER + os.sep + "1MB.test") - write_random_file(100 * 1024, SOURCE_FOLDER + os.sep + "subfolder" + os.sep + "100KB.test") - sha1_source = sha1_folder(SOURCE_FOLDER) - - print("Adding new backup") - # Add new backup - wait_for_clickable(By.LINK_TEXT, "Add backup").click() - - # Choose the "add new" option - wait_for_clickable(By.ID, "blank").click() - wait_for_load(By.XPATH, "//input[@class='submit next']").click() - - # Add new backup - General page - wait_for_load(By.ID, "name").send_keys(BACKUP_NAME) - wait_for_load(By.ID, "passphrase").send_keys(PASSWORD) - wait_for_load(By.ID, "repeat-passphrase").send_keys(PASSWORD) - wait_for_load(By.ID, "nextStep1").click() - - # Add new backup - Destination page - wait_for_load(By.LINK_TEXT, "Manually type path").click() - wait_for_load(By.ID, "file_path").send_keys(DESTINATION_FOLDER) - wait_for_load(By.ID, "nextStep2").click() - - # Add new backup - Source Data page - wait_for_load(By.ID, "sourcePath").send_keys(os.path.abspath(SOURCE_FOLDER) + os.sep) - wait_for_load(By.ID, "sourceFolderPathAdd").click() - wait_for_load(By.ID, "nextStep3").click() - - # Add new backup - Schedule page - useScheduleRun = wait_for_load(By.ID, "useScheduleRun") - if useScheduleRun.is_selected(): - useScheduleRun.click() - wait_for_load(By.ID, "nextStep4").click() - - # Add new backup - Options page - wait_for_clickable(By.ID, "save").click() - time.sleep(1) # Delay so page has time to load - - # Run the backup job and wait for finish - print("Running backup job") - wait_for_clickable(By.LINK_TEXT, BACKUP_NAME).click() - [n for n in driver.find_elements("xpath", "//dl[@class='taskmenu']/dd/p/span[contains(text(),'Run now')]") if n.is_displayed()][0].click() - wait_for_text("//div[@class='task ng-scope']/dl[2]/dd[1]", "(took ", 60) - - # Restore - print("Restoring") - if len([n for n in driver.find_elements("xpath", u"//span[contains(text(),'Restore files \u2026')]") if n.is_displayed()]) == 0: - wait_for_clickable(By.LINK_TEXT, BACKUP_NAME).click() - - [n for n in driver.find_elements("xpath", u"//span[contains(text(),'Restore files \u2026')]") if n.is_displayed()][0].click() - wait_for_load(By.XPATH, "//span[contains(text(),'" + SOURCE_FOLDER + "')]") # wait for filelist - time.sleep(1) # Delay so page has time to load - wait_for_clickable(By.XPATH, "//restore-file-picker/ul/li/div/a[2]").click() # select root folder checkbox - - wait_for_clickable(By.XPATH, "//form[@id='restore']/div[1]/div[@class='buttons']/a/span[contains(text(), 'Continue')]").click() - wait_for_clickable(By.ID, "restoretonewpath").click() - wait_for_load(By.ID, "restore_path").send_keys(RESTORE_FOLDER) - wait_for_clickable(By.XPATH, "//form[@id='restore']/div/div[@class='buttons']/a/span[contains(text(),'Restore')]").click() - - # wait for restore to finish - print("Waiting for restore to finish") - wait_for_text("//form[@id='restore']/div[3]/h3/div[1]", "Your files and folders have been restored successfully.", 60) - - # hash restored files - print("Restore completed, verifying hashes") - sha1_restore = sha1_folder(RESTORE_FOLDER) - - # cleanup: delete source and restore folder and rename destination folder for direct restore - if os.path.exists(SOURCE_FOLDER): - shutil.rmtree(SOURCE_FOLDER) - if os.path.exists(RESTORE_FOLDER): - shutil.rmtree(RESTORE_FOLDER) - os.rename(DESTINATION_FOLDER, DESTINATION_FOLDER_DIRECT_RESTORE) - - # direct restore - print("Starting direct restore") - wait_for_clickable(By.LINK_TEXT, "Restore").click() - - # Choose the "restore direct" option - wait_for_clickable(By.ID, "direct").click() - wait_for_clickable(By.XPATH, "//input[@class='submit next']").click() - - wait_for_clickable(By.LINK_TEXT, "Manually type path").click() - wait_for_load(By.ID, "file_path").send_keys(DESTINATION_FOLDER_DIRECT_RESTORE) - wait_for_clickable(By.ID, "nextStep1").click() - - print("Connecting to destination") - wait_for_load(By.ID, "password").send_keys(PASSWORD) - wait_for_clickable(By.ID, "connect").click() - - print("Waiting for filelist") - wait_for_load(By.XPATH, "//span[contains(text(),'" + SOURCE_FOLDER + "')]") # wait for filelist - - time.sleep(1) # Delay so page has time to load - wait_for_clickable(By.XPATH, "//restore-file-picker/ul/li/div/a[2]").click() # select root folder checkbox - wait_for_load(By.XPATH, "//form[@id='restore']/div[1]/div[@class='buttons']/a/span[contains(text(), 'Continue')]").click() - - print("Restoring files with direct restore") - wait_for_clickable(By.ID, "restoretonewpath").click() - wait_for_load(By.ID, "restore_path").send_keys(DIRECT_RESTORE_FOLDER) - wait_for_clickable(By.XPATH, "//form[@id='restore']/div/div[@class='buttons']/a/span[contains(text(),'Restore')]").click() - - # wait for restore to finish - print("Waiting for direct restore to finish") - wait_for_text("//form[@id='restore']/div[3]/h3/div[1]", "Your files and folders have been restored successfully.", 60) - - # hash direct restore files - print("Direct restore completed, verifying hashes") - sha1_direct_restore = sha1_folder(DIRECT_RESTORE_FOLDER) - - print("Source hashes: " + str(sha1_source)) - print("Restore hashes: " + str(sha1_restore)) - print("Direct Restore hashes: " + str(sha1_direct_restore)) - - # Tell Sauce Labs to stop the test - driver.quit() - - if not (sha1_source == sha1_restore and sha1_source == sha1_direct_restore): - sys.exit(1) # return with error - -try: - runTests() -except: - print("Test failed, emitting browser log lines: ") - for entry in driver.get_log('browser'): - print(entry) - raise From 51ffc47dc6c140388ab5dee0ccc63599d595f8d2 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Mon, 10 Nov 2025 19:50:32 +0100 Subject: [PATCH 18/19] Added tracing to debug failure --- .github/workflows/tests.yml | 36 ++++++++++++++++++++--- playwright-tests/backupRestore.spec.ts | 40 ++++++++++++++++++++++++++ playwright.config.ts | 8 +++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 58c01a593..d19f72fb4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -78,17 +78,45 @@ jobs: run: dotnet publish -c Debug -o published Duplicati.sln - name: Start server run: | - ./published/Duplicati.Server --disable-database-encryption --webservice-password=easy1234 & + ./published/Duplicati.Server --disable-database-encryption --webservice-password=easy1234 > server.log 2>&1 & + SERVER_PID=$! + echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV timeout 30 bash -c 'until printf "" 2>>/dev/null >>/dev/tcp/127.0.0.1/8200; do sleep 1; echo waiting; done' + echo "Server started with PID: $SERVER_PID" - name: Load web UI - run: curl -f http://localhost:8200/ngclient/index.html + run: | + curl -f http://localhost:8200/ngclient/index.html + echo "Web UI loaded successfully" + - name: Check server status + run: | + echo "Server process status:" + ps aux | grep Duplicati.Server || true + echo "Server log (last 50 lines):" + tail -n 50 server.log || true - name: Run Playwright tests - run: npx playwright test + run: npx playwright test --reporter=list,html,github + + - name: Capture server logs on failure + if: failure() + run: | + echo "=== Full Server Log ===" + cat server.log || echo "No server log found" - name: Upload Playwright test results on failure if: failure() uses: actions/upload-artifact@v4 with: name: playwright-test-results - path: test-results/ + path: | + test-results/ + playwright-report/ + server.log + retention-days: 7 + + - name: Upload Playwright HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-html-report + path: playwright-report/ retention-days: 7 diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index 5a57cb283..e27d7245c 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -144,6 +144,22 @@ async function createBackup(page: Page) { async function deleteBackupIfExists(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); + + // 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 @@ -303,6 +319,10 @@ async function restoreFromConfigFile(page: 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([ @@ -314,13 +334,33 @@ test("backup and restore flow", async ({ page }) => { 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); diff --git a/playwright.config.ts b/playwright.config.ts index 20a06dc1f..a863636fe 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,10 +4,16 @@ export default defineConfig({ use: { baseURL: "http://localhost:8200", headless: true, + // Capture screenshots on failure + screenshot: "only-on-failure", + // Capture video on failure + video: "retain-on-failure", + // Capture trace on failure for detailed debugging + trace: "retain-on-failure", }, testDir: "playwright-tests", timeout: 120000, workers: 1, - reporter: process.env.CI ? "html" : "list", + reporter: process.env.CI ? [["html"], ["list"], ["github"]] : "list", outputDir: "test-results/", }); From be1be8ff0dabc5629ee2498d61a7b3a04ce50627 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Tue, 11 Nov 2025 09:52:27 +0100 Subject: [PATCH 19/19] Handle tests with empty backup list --- playwright-tests/backupRestore.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts index e27d7245c..24af798eb 100644 --- a/playwright-tests/backupRestore.spec.ts +++ b/playwright-tests/backupRestore.spec.ts @@ -90,7 +90,7 @@ async function completeRestoreFlow(page: Page) { async function createBackup(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); - await page.locator("div.backup").first().waitFor(); + 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(); @@ -145,6 +145,13 @@ async function deleteBackupIfExists(page: Page) { await page.goto(HOME_URL); await page.waitForLoadState("networkidle"); + try { + await page.locator("div.backup").first().waitFor({ timeout: 5000 }); + } catch (e) { + console.log("No backup elements found, skipping deletion."); + return; + } + // Take a screenshot before waiting for backup elements await page.screenshot({ path: path.join("test-results", "before-backup-wait.png"),