mirror of
https://github.com/duplicati/duplicati.git
synced 2026-05-06 07:16:38 -04:00
Merge pull request #6599 from duplicati/feature/add-playwright-tests
Initial version of a PlayWright test using ngclient
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+82
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
WEBSERVICE_PASSWORD=easy1234 npx playwright test --headed
|
||||
@@ -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/",
|
||||
});
|
||||
Reference in New Issue
Block a user