diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e7ff6f1c6..e6ebc5f6e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 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 diff --git a/package-lock.json b/package-lock.json index cecab55e6..ece29ab50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0261e4a9a..83eefe3d1 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/playwright-tests/backupRestore.spec.ts b/playwright-tests/backupRestore.spec.ts new file mode 100644 index 000000000..24af798eb --- /dev/null +++ b/playwright-tests/backupRestore.spec.ts @@ -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); +}); 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 diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..a863636fe --- /dev/null +++ b/playwright.config.ts @@ -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/", +});