Merge pull request #6599 from duplicati/feature/add-playwright-tests

Initial version of a PlayWright test using ngclient
This commit is contained in:
Kenneth Skovhede
2025-11-11 10:51:41 +01:00
committed by GitHub
7 changed files with 562 additions and 328 deletions
+61 -7
View File
@@ -77,10 +77,64 @@ jobs:
files: ${{ github.workspace }}/TestResults/integration/**/coverage.cobertura.xml
flags: integration,${{ matrix.os }}
# 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 -c Debug -o published Duplicati.sln
- name: Start server
run: |
./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
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 --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/
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
-320
View File
@@ -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
+82
View File
@@ -5,6 +5,8 @@
"packages": {
"": {
"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",
@@ -193,6 +195,22 @@
"node": ">= 8"
}
},
"node_modules/@playwright/test": {
"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.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
@@ -206,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",
@@ -1545,6 +1573,53 @@
"node": ">=6"
}
},
"node_modules/playwright": {
"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.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"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": ">=18"
}
},
"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",
@@ -2386,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",
+4 -1
View File
@@ -1,5 +1,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",
@@ -11,6 +13,7 @@
"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"
}
}
+394
View File
@@ -0,0 +1,394 @@
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();
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");
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"),
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);
});
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
WEBSERVICE_PASSWORD=easy1234 npx playwright test --headed
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "@playwright/test";
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"], ["github"]] : "list",
outputDir: "test-results/",
});