diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56b71b7b..09a558ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" @@ -67,18 +67,18 @@ jobs: python-typecheck: name: Python Type Check runs-on: ubuntu-latest - continue-on-error: true steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" - - name: Install Pyright - run: pip install pyright + - name: Install dependencies + working-directory: src/python + run: pip install -r requirements.txt pyright - name: Pyright working-directory: src/python @@ -93,7 +93,7 @@ jobs: uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" cache: pip @@ -172,16 +172,42 @@ jobs: cache-from: type=gha,scope=amd64 cache-to: type=gha,scope=amd64,mode=max - - name: Test container starts + - name: Start container run: | - docker run -d --name test-container -p 8800:8800 seedsync:test + docker run -d --name test-container -p 8800:8800 -e SEEDSYNC_DISABLE_RATE_LIMIT=1 seedsync:test for i in $(seq 1 30); do curl -sf http://localhost:8800/ && break [ "$i" -eq 30 ] && { echo "Container failed to become ready"; docker logs test-container; exit 1; } sleep 1 done docker logs test-container - docker stop test-container + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Install Playwright + working-directory: src/e2e-playwright + run: | + npm install + npx playwright install --with-deps chromium + + - name: Run E2E tests + working-directory: src/e2e-playwright + run: npx playwright test + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: src/e2e-playwright/playwright-report/ + retention-days: 7 + + - name: Stop container + if: always() + run: docker stop test-container build-amd64: name: Build (amd64) @@ -334,10 +360,11 @@ jobs: && (startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/develop' && github.event_name == 'push') || github.event_name == 'workflow_dispatch') && needs.unit-test.result == 'success' && needs.python-lint.result == 'success' + && needs.python-typecheck.result == 'success' && needs.python-test.result == 'success' && needs.build-amd64.result == 'success' && needs.build-arm64.result == 'success' - needs: [unit-test, python-lint, python-test, build-amd64, build-arm64] + needs: [unit-test, python-lint, python-typecheck, python-test, build-amd64, build-arm64] runs-on: ubuntu-latest permissions: contents: read diff --git a/.gitignore b/.gitignore index 0946734b..b835059a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ package-lock.json src/python/build src/python/site dev-config/ +/test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a4bed0f7..5692ef02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [0.14.2] - 2026-03-23 + +### Added + +- **Playwright E2E test suite** — Replaced legacy Protractor tests with Playwright; 55 tests covering all pages, navigation, themes, and settings (#250) +- **Pyright type checking enforced in CI** — Completed Pyright phases 3 & 4, fixing 166 type errors to reach 0 errors in basic mode; Pyright check is now required in CI (#249) + +### Fixed + +- **ModelFile nullable size fields** — `local_size` and `remote_size` are now correctly typed as `number | null` to match the Python backend JSON contract + +### Security + +- **Reject control characters in filenames** — Decoded filenames containing control characters are now rejected to prevent corrupted file entries and queue command injection (#300) +- **Redact sensitive credentials from API** — SSH password and key passphrase are no longer exposed in API responses; set handler rejects the redacted sentinel value (#257) + +### Changed + +- **Angular dependency updates** — Bumped Angular group to latest, jsdom to 29.0.1 (#307, #308) +- **CI: actions/setup-python v6** — Bumped setup-python action from v5 to v6 (#306) + +### Removed + +- **Legacy Docker E2E infrastructure** — Cleaned up Protractor Docker Compose files, test images, and fixture data + ## [0.14.1] - 2026-03-18 ### Fixed diff --git a/Makefile b/Makefile index b1a0ed88..e798f36a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # SeedSync Makefile - Docker Only # Simplified build system for containerized deployment -.PHONY: all build build-fresh run stop logs clean test test-image size shell help +.PHONY: all build build-fresh run stop logs clean test test-image test-e2e test-e2e-headed test-e2e-report size shell help # Default target all: build @@ -41,6 +41,32 @@ test: docker run --rm -v $(PWD)/src/python:/app/python seedsync-test \ pytest tests/unittests -v --tb=short +# Run Playwright E2E tests with a throwaway Docker container +test-e2e-docker: + @docker rm -f seedsync-e2e-test 2>/dev/null || true + docker run -d --name seedsync-e2e-test -p 8801:8800 -e SEEDSYNC_DISABLE_RATE_LIMIT=1 ghcr.io/nitrobass24/seedsync:latest + @echo "Waiting for container to start..." + @for i in $$(seq 1 30); do \ + curl -sf http://localhost:8801/ > /dev/null 2>&1 && break; \ + sleep 1; \ + done + cd src/e2e-playwright && BASE_URL=http://localhost:8801 npx playwright test; \ + exit_code=$$?; \ + docker rm -f seedsync-e2e-test; \ + exit $$exit_code + +# Run Playwright E2E tests (headless, requires running container on port 8800) +test-e2e: + cd src/e2e-playwright && npx playwright test + +# Run Playwright E2E tests (headed, for debugging) +test-e2e-headed: + cd src/e2e-playwright && npx playwright test --headed + +# Show Playwright HTML report +test-e2e-report: + cd src/e2e-playwright && npx playwright show-report + # Show image size size: @docker images seedsync-seedsync --format "Image size: {{.Size}}" @@ -64,5 +90,9 @@ help: @echo " clean - Remove containers and images" @echo " test - Run Python unit tests (in Docker)" @echo " test-image - Build cached test image" + @echo " test-e2e-docker - Run E2E tests inside a fresh Docker container" + @echo " test-e2e - Run Playwright E2E tests (headless)" + @echo " test-e2e-headed - Run E2E tests with browser visible" + @echo " test-e2e-report - Show Playwright HTML report" @echo " size - Show image size" @echo " shell - Open shell in running container" diff --git a/src/angular/package-lock.json b/src/angular/package-lock.json index 4f302e2d..8e39cf64 100644 --- a/src/angular/package-lock.json +++ b/src/angular/package-lock.json @@ -1,19 +1,19 @@ { "name": "seedsync", - "version": "0.14.0", + "version": "0.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seedsync", - "version": "0.14.0", - "dependencies": { - "@angular/common": "^21.2.4", - "@angular/compiler": "^21.2.4", - "@angular/core": "^21.2.4", - "@angular/forms": "^21.2.4", - "@angular/platform-browser": "^21.2.4", - "@angular/router": "^21.2.4", + "version": "0.14.1", + "dependencies": { + "@angular/common": "^21.2.5", + "@angular/compiler": "^21.2.5", + "@angular/core": "^21.2.5", + "@angular/forms": "^21.2.5", + "@angular/platform-browser": "^21.2.5", + "@angular/router": "^21.2.5", "@fortawesome/fontawesome-free": "^7.1.0", "bootstrap": "^5.3.8", "compare-versions": "^6.1.1", @@ -21,10 +21,10 @@ "tslib": "^2.3.0" }, "devDependencies": { - "@angular/build": "^21.2.2", - "@angular/cli": "^21.2.2", - "@angular/compiler-cli": "^21.2.4", - "jsdom": "^29.0.0", + "@angular/build": "^21.2.3", + "@angular/cli": "^21.2.3", + "@angular/compiler-cli": "^21.2.5", + "jsdom": "^29.0.1", "typescript": "~5.9.2", "vitest": "^4.1.0" } @@ -253,13 +253,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2102.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.2.tgz", - "integrity": "sha512-CDvFtXwyBtMRkTQnm+LfBNLL0yLV8ZGskrM1T6VkcGwXGFDott1FxUdj96ViodYsYL5fbJr0MNA6TlLcanV3kQ==", + "version": "0.2102.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.3.tgz", + "integrity": "sha512-G4wSWUbtWp1WCKw5GMRqHH8g4m5RBpIyzt8n8IX5Pm6iYe/rwCBSKL3ktEkk7AYMwjtonkRlDtAK1GScFsf1Sg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.2", + "@angular-devkit/core": "21.2.3", "rxjs": "7.8.2" }, "bin": { @@ -272,9 +272,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.2.tgz", - "integrity": "sha512-xUeKGe4BDQpkz0E6fnAPIJXE0y0nqtap0KhJIBhvN7xi3NenIzTmoi6T9Yv5OOBUdLZbOm4SOel8MhdXiIBpAQ==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.3.tgz", + "integrity": "sha512-i++JVHOijyFckjdYqKbSXUpKnvmO2a0Utt/wQVwiLAT0O9H1hR/2NGPzubB4hnLMNSyVWY8diminaF23mZ0xjA==", "dev": true, "license": "MIT", "dependencies": { @@ -300,13 +300,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "21.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.2.tgz", - "integrity": "sha512-CCeyQxGUq+oyGnHd7PfcYIVbj9pRnqjQq0rAojoAqs1BJdtInx9weLBCLy+AjM3NHePeZrnwm+wEVr8apED8kg==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.3.tgz", + "integrity": "sha512-tc/bBloRTVIBWGRiMPln1QbW+2QPj+YnWL/nG79abLKWkdrL9dJLcCRXY7dsPNrxOc/QF+8tVpnr8JofhWL9cQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.2", + "@angular-devkit/core": "21.2.3", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -319,14 +319,14 @@ } }, "node_modules/@angular/build": { - "version": "21.2.2", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.2.tgz", - "integrity": "sha512-Vq2eIneNxzhHm1MwEmRqEJDwHU9ODfSRDaMWwtysGMhpoMQmLdfTqkQDmkC2qVUr8mV8Z1i5I+oe5ZJaMr/PlQ==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.3.tgz", + "integrity": "sha512-u4bhVQruK7KOuHQuoltqlHg+szp0f6rnsGIUolJnT3ez5V6OuSoWIxUorSbvryi2DiKRD/3iwMq7qJN1aN9HCA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.2", + "@angular-devkit/architect": "0.2102.3", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -369,7 +369,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.2", + "@angular/ssr": "^21.2.3", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -419,19 +419,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.2", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.2.tgz", - "integrity": "sha512-eZo8/qX+ZIpIWc0CN+cCX13Lbgi/031wAp8DRVhDDO6SMVtcr/ObOQ2S16+pQdOMXxiG3vby6IhzJuz9WACzMQ==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.3.tgz", + "integrity": "sha512-QzDxnSy8AUOz6ca92xfbNuEmRdWRDi1dfFkxDVr+4l6XUnA9X6VmOi7ioCO1I9oDR73LXHybOqkqHBYDlqt/Ag==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.2", - "@angular-devkit/core": "21.2.2", - "@angular-devkit/schematics": "21.2.2", + "@angular-devkit/architect": "0.2102.3", + "@angular-devkit/core": "21.2.3", + "@angular-devkit/schematics": "21.2.3", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.2", + "@schematics/angular": "21.2.3", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -454,9 +454,9 @@ } }, "node_modules/@angular/common": { - "version": "21.2.4", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.4.tgz", - "integrity": "sha512-NrP6qOuUpo3fqq14UJ1b2bIRtWsfvxh1qLqOyFV4gfBrHhXd0XffU1LUlUw1qp4w1uBSgPJ0/N5bSPUWrAguVg==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.5.tgz", + "integrity": "sha512-MTjCbsHBkF9W12CW9yYiTJdVfZv/qCqBCZ2iqhMpDA5G+ZJiTKP0IDTJVrx2N5iHfiJ1lnK719t/9GXROtEAvg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -465,14 +465,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.4", + "@angular/core": "21.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.4", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.4.tgz", - "integrity": "sha512-9+ulVK3idIo/Tu4X2ic7/V0+Uj7pqrOAbOuIirYe6Ymm3AjexuFRiGBbfcH0VJhQ5cf8TvIJ1fuh+MI4JiRIxA==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.5.tgz", + "integrity": "sha512-QloEsknGqLvmr+ED7QShDt7SoMY9mipV+gVnwn4hBI5sbl+TOBfYWXIaJMnxseFwSqjXTSCVGckfylIlynNcFg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -482,9 +482,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "21.2.4", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.4.tgz", - "integrity": "sha512-vGjd7DZo/Ox50pQCm5EycmBu91JclimPtZoyNXu/2hSxz3oAkzwiHCwlHwk2g58eheSSp+lYtYRLmHAqSVZLjg==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.5.tgz", + "integrity": "sha512-Ox3vz6KAM7i47ujR/3M3NCOeCRn6vrC9yV1SHZRhSrYg6CWWcOMveavEEwtNjYtn3hOzrktO4CnuVwtDbU8pLg==", "dev": true, "license": "MIT", "dependencies": { @@ -505,7 +505,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.4", + "@angular/compiler": "21.2.5", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -515,9 +515,9 @@ } }, "node_modules/@angular/core": { - "version": "21.2.4", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.4.tgz", - "integrity": "sha512-2+gd67ZuXHpGOqeb2o7XZPueEWEP81eJza2tSHkT5QMV8lnYllDEmaNnkPxnIjSLGP1O3PmiXxo4z8ibHkLZwg==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.5.tgz", + "integrity": "sha512-JgHU134Adb1wrpyGC9ozcv3hiRAgaFTvJFn1u9OU/AVXyxu4meMmVh2hp5QhAvPnv8XQdKWWIkAY+dbpPE6zKA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -526,7 +526,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.4", + "@angular/compiler": "21.2.5", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -540,9 +540,9 @@ } }, "node_modules/@angular/forms": { - "version": "21.2.4", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.4.tgz", - "integrity": "sha512-1fOhctA9ADEBYjI3nPQUR5dHsK2+UWAjup37Ksldk/k0w8UpD5YsN7JVNvsDMZRFMucKYcGykPblU7pABtsqnQ==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.5.tgz", + "integrity": "sha512-pqRuK+a1ZAFZbs8/dZoorFJah2IWaf/SH8axHUpaDJ7fyNrwNEcpczyObdxZ00lOgORpKAhWo/q0hlVS+In8cw==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -552,16 +552,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.4", - "@angular/core": "21.2.4", - "@angular/platform-browser": "21.2.4", + "@angular/common": "21.2.5", + "@angular/core": "21.2.5", + "@angular/platform-browser": "21.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "21.2.4", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.4.tgz", - "integrity": "sha512-1A9e/cQVu+3BkRCktLcO3RZGuw8NOTHw1frUUrpAz+iMyvIT4sDRFbL+U1g8qmOCZqRNC1Pi1HZfZ1kl6kvrcQ==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.5.tgz", + "integrity": "sha512-VuuYguxjgyI4XWuoXrKynmuA3FB991pXbkNhxHeCW0yX+7DGOnGLPF1oierd4/X+IvskmN8foBZLfjyg9u4Ffg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -570,9 +570,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "21.2.4", - "@angular/common": "21.2.4", - "@angular/core": "21.2.4" + "@angular/animations": "21.2.5", + "@angular/common": "21.2.5", + "@angular/core": "21.2.5" }, "peerDependenciesMeta": { "@angular/animations": { @@ -581,9 +581,9 @@ } }, "node_modules/@angular/router": { - "version": "21.2.4", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.4.tgz", - "integrity": "sha512-OjWze4XT8i2MThcBXMv7ru1k6/5L6QYZbcXuseqimFCHm2avEJ+mXPovY066fMBZJhqbXdjB82OhHAWkIHjglQ==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.5.tgz", + "integrity": "sha512-yQGhTVGvh8OMW3auj13+g+OCSQj7gyBQON/2X4LuCvIUG71NPV6Fqzfk9DKTKaXpqo0FThy8/LPJ0Lsy3CRejg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -592,9 +592,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.4", - "@angular/core": "21.2.4", - "@angular/platform-browser": "21.2.4", + "@angular/common": "21.2.5", + "@angular/core": "21.2.5", + "@angular/platform-browser": "21.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -3771,14 +3771,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "21.2.2", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.2.tgz", - "integrity": "sha512-Ywa6HDtX7TRBQZTVMMnxX3Mk7yVnG8KtSFaXWrkx779+q8tqYdBwNwAqbNd4Zatr1GccKaz9xcptHJta5+DTxw==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.3.tgz", + "integrity": "sha512-rCEprgpNbJLl9Rm/t92eRYc1eIqD4BAJqB1OO8fzQolyDajCcOBpohjXkuLYSwK9RMyS6f+szNnYGOQawlrPYw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.2", - "@angular-devkit/schematics": "21.2.2", + "@angular-devkit/core": "21.2.3", + "@angular-devkit/schematics": "21.2.3", "jsonc-parser": "3.3.1" }, "engines": { @@ -5852,14 +5852,14 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", - "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.2", + "@asamuzakjp/dom-selector": "^7.0.3", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", @@ -5873,7 +5873,7 @@ "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.24.3", + "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -5903,9 +5903,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", "dev": true, "license": "MIT", "engines": { diff --git a/src/angular/package.json b/src/angular/package.json index d58fa90e..d2cf6062 100644 --- a/src/angular/package.json +++ b/src/angular/package.json @@ -1,6 +1,6 @@ { "name": "seedsync", - "version": "0.14.1", + "version": "0.14.2", "scripts": { "ng": "ng", "start": "ng serve", @@ -23,12 +23,12 @@ "private": true, "packageManager": "npm@10.9.2", "dependencies": { - "@angular/common": "^21.2.4", - "@angular/compiler": "^21.2.4", - "@angular/core": "^21.2.4", - "@angular/forms": "^21.2.4", - "@angular/platform-browser": "^21.2.4", - "@angular/router": "^21.2.4", + "@angular/common": "^21.2.5", + "@angular/compiler": "^21.2.5", + "@angular/core": "^21.2.5", + "@angular/forms": "^21.2.5", + "@angular/platform-browser": "^21.2.5", + "@angular/router": "^21.2.5", "@fortawesome/fontawesome-free": "^7.1.0", "bootstrap": "^5.3.8", "compare-versions": "^6.1.1", @@ -36,10 +36,10 @@ "tslib": "^2.3.0" }, "devDependencies": { - "@angular/build": "^21.2.2", - "@angular/cli": "^21.2.2", - "@angular/compiler-cli": "^21.2.4", - "jsdom": "^29.0.0", + "@angular/build": "^21.2.3", + "@angular/cli": "^21.2.3", + "@angular/compiler-cli": "^21.2.5", + "jsdom": "^29.0.1", "typescript": "~5.9.2", "vitest": "^4.1.0" } diff --git a/src/angular/src/app/models/config.ts b/src/angular/src/app/models/config.ts index 8a3eb38c..c60e0fae 100644 --- a/src/angular/src/app/models/config.ts +++ b/src/angular/src/app/models/config.ts @@ -75,6 +75,9 @@ export interface Validate { xfer_verify: boolean | null; } +/** Sentinel value the backend uses to mask sensitive fields in API responses. */ +export const REDACTED_SENTINEL = '********'; + export interface Config { general: General; lftp: Lftp; diff --git a/src/angular/src/app/pages/settings/option.component.ts b/src/angular/src/app/pages/settings/option.component.ts index abaaceed..a38fe417 100644 --- a/src/angular/src/app/pages/settings/option.component.ts +++ b/src/angular/src/app/pages/settings/option.component.ts @@ -1,6 +1,7 @@ import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, input, output, computed } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Subject, Subscription, debounceTime, distinctUntilChanged } from 'rxjs'; +import { REDACTED_SENTINEL } from '../../models/config'; export enum OptionType { Text, @@ -54,6 +55,12 @@ export class OptionComponent implements OnInit, OnDestroy { } onChange(value: any): void { + // Don't send updates for password fields when the value is the redacted sentinel. + // The server returns "********" for sensitive fields; sending it back would + // overwrite the real password with the sentinel. + if (this.type() === OptionType.Password && value === REDACTED_SENTINEL) { + return; + } this.newValue.next(value); } } diff --git a/src/angular/src/app/services/files/view-file.service.spec.ts b/src/angular/src/app/services/files/view-file.service.spec.ts index 4768a7a5..c5ec6778 100644 --- a/src/angular/src/app/services/files/view-file.service.spec.ts +++ b/src/angular/src/app/services/files/view-file.service.spec.ts @@ -286,6 +286,60 @@ describe("ViewFileService", () => { expect(latestFiles()[0].isRemotelyDeletable).toBe(true); }); + // --- isValidatable and validateTooltip --- + + it("should set isValidatable when status allows and both sizes are non-null", () => { + emitModelFiles([ + makeModelFile({ + name: "valid", + local_size: 100, + remote_size: 100, + state: ModelFileState.DOWNLOADED, + }), + ]); + expect(latestFiles()[0].isValidatable).toBe(true); + expect(latestFiles()[0].validateTooltip).toBeNull(); + }); + + it("should not set isValidatable when remote_size is null and show tooltip", () => { + emitModelFiles([ + makeModelFile({ + name: "no-remote", + local_size: 100, + remote_size: null, + state: ModelFileState.DOWNLOADED, + }), + ]); + expect(latestFiles()[0].isValidatable).toBe(false); + expect(latestFiles()[0].validateTooltip).toBe("Remote file not available for checksum comparison"); + }); + + it("should not set isValidatable when local_size is null", () => { + emitModelFiles([ + makeModelFile({ + name: "no-local", + local_size: null, + remote_size: 100, + state: ModelFileState.DOWNLOADED, + }), + ]); + expect(latestFiles()[0].isValidatable).toBe(false); + expect(latestFiles()[0].validateTooltip).toBeNull(); + }); + + it("should not set isValidatable for non-validatable status", () => { + emitModelFiles([ + makeModelFile({ + name: "queued", + local_size: 100, + remote_size: 100, + state: ModelFileState.QUEUED, + }), + ]); + expect(latestFiles()[0].isValidatable).toBe(false); + expect(latestFiles()[0].validateTooltip).toBeNull(); + }); + // --- Filter criteria --- it("should apply filter criteria to filteredFiles$", () => { diff --git a/src/docker/test/e2e/Dockerfile b/src/docker/test/e2e/Dockerfile deleted file mode 100644 index c1ce0387..00000000 --- a/src/docker/test/e2e/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# Creates environment for e2e tests -FROM node:20-bookworm-slim as seedsync_test_e2e_env - -COPY src/e2e/package*.json /app/ -WORKDIR /app -RUN npm install - - -# Builds and runs e2e tests -FROM seedsync_test_e2e_env as seedsync_test_e2e - -COPY \ - src/e2e/conf.ts \ - src/e2e/tsconfig.json \ - /app/ -COPY src/e2e/tests /app/tests -COPY \ - src/docker/test/e2e/urls.ts \ - src/docker/test/e2e/run_tests.sh \ - src/docker/test/e2e/parse_seedsync_status.py \ - /app/ - -WORKDIR /app - -RUN node_modules/typescript/bin/tsc --outDir ./tmp - -CMD ["/app/run_tests.sh"] diff --git a/src/docker/test/e2e/chrome/Dockerfile b/src/docker/test/e2e/chrome/Dockerfile deleted file mode 100644 index 8f114eb9..00000000 --- a/src/docker/test/e2e/chrome/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM yukinying/chrome-headless-browser-selenium:latest - -USER root -RUN apt-get update && apt-get install -y libxi6 libgconf-2-4 -USER headless diff --git a/src/docker/test/e2e/compose-dev.yml b/src/docker/test/e2e/compose-dev.yml deleted file mode 100644 index 03cc8310..00000000 --- a/src/docker/test/e2e/compose-dev.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: "3.4" -services: - tests: - command: /bin/true - - myapp: - ports: - - target: 8800 - published: 8800 - protocol: tcp - mode: host - - chrome: - ports: - - target: 4444 - published: 4444 - protocol: tcp - mode: host diff --git a/src/docker/test/e2e/compose.yml b/src/docker/test/e2e/compose.yml deleted file mode 100644 index c9ba0a6d..00000000 --- a/src/docker/test/e2e/compose.yml +++ /dev/null @@ -1,38 +0,0 @@ -version: "3.4" -services: - tests: - image: seedsync/test/e2e - container_name: seedsync_test_e2e - build: - context: ../../../../ - dockerfile: src/docker/test/e2e/Dockerfile - target: seedsync_test_e2e - depends_on: - - chrome - - remote - - chrome: - image: seedsync/test/e2e/chrome - container_name: seedsync_test_e2e_chrome - build: - context: ../../../../ - dockerfile: src/docker/test/e2e/chrome/Dockerfile - shm_size: 1024M - cap_add: - - SYS_ADMIN - - remote: - image: seedsync/test/e2e/remote - container_name: seedsync_test_e2e_remote - build: - context: ../../../../ - dockerfile: src/docker/test/e2e/remote/Dockerfile - - configure: - image: seedsync/test/e2e/configure - container_name: seedsync_test_e2e_configure - build: - context: ../../../../ - dockerfile: src/docker/test/e2e/configure/Dockerfile - depends_on: - - myapp diff --git a/src/docker/test/e2e/configure/Dockerfile b/src/docker/test/e2e/configure/Dockerfile deleted file mode 100644 index 848013de..00000000 --- a/src/docker/test/e2e/configure/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM alpine:3.11.6 - -RUN apk add --no-cache curl bash - -WORKDIR / -ADD src/docker/wait-for-it.sh / -ADD src/docker/test/e2e/configure/setup_seedsync.sh / -CMD ["/setup_seedsync.sh"] diff --git a/src/docker/test/e2e/configure/setup_seedsync.sh b/src/docker/test/e2e/configure/setup_seedsync.sh deleted file mode 100755 index 56a69782..00000000 --- a/src/docker/test/e2e/configure/setup_seedsync.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -./wait-for-it.sh myapp:8800 -- echo "Seedsync app is up (before configuring)" -curl -sS "http://myapp:8800/server/config/set/general/debug/true"; echo -curl -sS "http://myapp:8800/server/config/set/general/verbose/true"; echo -curl -sS "http://myapp:8800/server/config/set/lftp/local_path/%252Fdownloads"; echo -curl -sS "http://myapp:8800/server/config/set/lftp/remote_address/remote"; echo -curl -sS "http://myapp:8800/server/config/set/lftp/remote_username/remoteuser"; echo -curl -sS "http://myapp:8800/server/config/set/lftp/remote_password/remotepass"; echo -curl -sS "http://myapp:8800/server/config/set/lftp/remote_port/1234"; echo -curl -sS "http://myapp:8800/server/config/set/lftp/remote_path/%252Fhome%252Fremoteuser%252Ffiles"; echo -curl -sS "http://myapp:8800/server/config/set/autoqueue/patterns_only/true"; echo - -curl -sS "http://myapp:8800/server/command/restart"; echo - -./wait-for-it.sh myapp:8800 -- echo "Seedsync app is up (after configuring)" - -echo -echo "Done configuring SeedSync app" diff --git a/src/docker/test/e2e/parse_seedsync_status.py b/src/docker/test/e2e/parse_seedsync_status.py deleted file mode 100644 index 7611982e..00000000 --- a/src/docker/test/e2e/parse_seedsync_status.py +++ /dev/null @@ -1,7 +0,0 @@ -import sys -import json - -try: - print(json.load(sys.stdin)["server"]["up"]) -except Exception: - print("False") diff --git a/src/docker/test/e2e/remote/Dockerfile b/src/docker/test/e2e/remote/Dockerfile deleted file mode 100644 index 0c6e43c6..00000000 --- a/src/docker/test/e2e/remote/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -FROM ubuntu:18.04 - -# Install dependencies -RUN apt-get update && apt-get install -y \ - python3.7 - -# Switch to Python 3.7 -RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.7 1 -RUN update-alternatives --set python /usr/bin/python3.7 - -# Create non-root user -RUN useradd --create-home -s /bin/bash remoteuser && \ - echo "remoteuser:remotepass" | chpasswd - - -# Add install image's user's key to authorized -USER remoteuser -ADD --chown=remoteuser:remoteuser src/docker/test/e2e/remote/id_rsa.pub /home/remoteuser/user_id_rsa.pub -RUN mkdir -p /home/remoteuser/.ssh && \ - cat /home/remoteuser/user_id_rsa.pub >> /home/remoteuser/.ssh/authorized_keys -USER root - -# Copy over data -ADD --chown=remoteuser:remoteuser src/docker/test/e2e/remote/files /home/remoteuser/files - -# Install and run ssh server -RUN apt-get update && apt-get install -y openssh-server - -# Change port -RUN sed -i '/Port 22/c\Port 1234' /etc/ssh/sshd_config -EXPOSE 1234 - -RUN mkdir /var/run/sshd - -CMD ["/usr/sbin/sshd", "-D"] diff --git a/src/docker/test/e2e/remote/files/clients.jpg b/src/docker/test/e2e/remote/files/clients.jpg deleted file mode 100644 index 1e0b3d67..00000000 Binary files a/src/docker/test/e2e/remote/files/clients.jpg and /dev/null differ diff --git a/src/docker/test/e2e/remote/files/crispycat/cat.mp4 b/src/docker/test/e2e/remote/files/crispycat/cat.mp4 deleted file mode 100644 index 2cd76b24..00000000 Binary files a/src/docker/test/e2e/remote/files/crispycat/cat.mp4 and /dev/null differ diff --git a/src/docker/test/e2e/remote/files/documentation.png b/src/docker/test/e2e/remote/files/documentation.png deleted file mode 100644 index 1730debb..00000000 Binary files a/src/docker/test/e2e/remote/files/documentation.png and /dev/null differ diff --git a/src/docker/test/e2e/remote/files/goose/goose.mp4 b/src/docker/test/e2e/remote/files/goose/goose.mp4 deleted file mode 100644 index bec286c4..00000000 Binary files a/src/docker/test/e2e/remote/files/goose/goose.mp4 and /dev/null differ diff --git a/src/docker/test/e2e/remote/files/illusion.jpg b/src/docker/test/e2e/remote/files/illusion.jpg deleted file mode 100644 index 764ad16f..00000000 Binary files a/src/docker/test/e2e/remote/files/illusion.jpg and /dev/null differ diff --git a/src/docker/test/e2e/remote/files/joke/joke.png b/src/docker/test/e2e/remote/files/joke/joke.png deleted file mode 100644 index f777f49e..00000000 Binary files a/src/docker/test/e2e/remote/files/joke/joke.png and /dev/null differ diff --git a/src/docker/test/e2e/remote/files/testing.gif b/src/docker/test/e2e/remote/files/testing.gif deleted file mode 100644 index 07c463bf..00000000 Binary files a/src/docker/test/e2e/remote/files/testing.gif and /dev/null differ diff --git "a/src/docker/test/e2e/remote/files/\303\241\303\237\303\247 d\303\251\303\200.mp4" "b/src/docker/test/e2e/remote/files/\303\241\303\237\303\247 d\303\251\303\200.mp4" deleted file mode 100644 index 74fa001c..00000000 Binary files "a/src/docker/test/e2e/remote/files/\303\241\303\237\303\247 d\303\251\303\200.mp4" and /dev/null differ diff --git "a/src/docker/test/e2e/remote/files/\303\274\303\246\303\222/\302\265\302\256\302\251 \303\267\303\272\306\244.png" "b/src/docker/test/e2e/remote/files/\303\274\303\246\303\222/\302\265\302\256\302\251 \303\267\303\272\306\244.png" deleted file mode 100644 index dd25c974..00000000 Binary files "a/src/docker/test/e2e/remote/files/\303\274\303\246\303\222/\302\265\302\256\302\251 \303\267\303\272\306\244.png" and /dev/null differ diff --git a/src/docker/test/e2e/remote/id_rsa.pub b/src/docker/test/e2e/remote/id_rsa.pub deleted file mode 100644 index 491f8496..00000000 --- a/src/docker/test/e2e/remote/id_rsa.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDg+fUnUskqL3/ETVdplm5LN1/A9DKSpud68LHWJKrxxYDyt25lxH2xjt3IxzrFDxhDTydmdHK8x/F2a8gOLKbLv+6DFLsLAKhf38IwvDXXL1HJPq2Gu292pIWhAM/J1aWCZjKrbwVhEVakqCZaCj3GATUvLBXE4a8jufGvZQ+r9szZPwLLgMgRxV7qNTU1HmiRWDhQl4GXpOgA5DmqM5I+Ae0tCLW+oe1OLkEOQLUCQG/YUjUfTTtnU7iSBBSEF3dah4MVNu9D7cUF+NFlEuAVdHv9CsJrpF6cj2qedaen6hHNqpE1GTtUbW0L38SfcqH3W3elWPfBdT2XKBeFOzrr user@572e13b2bdf3 \ No newline at end of file diff --git a/src/docker/test/e2e/run_tests.sh b/src/docker/test/e2e/run_tests.sh deleted file mode 100755 index ff111396..00000000 --- a/src/docker/test/e2e/run_tests.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash - -red=`tput setaf 1` -green=`tput setaf 2` -reset=`tput sgr0` - -END=$((SECONDS+10)) -while [ ${SECONDS} -lt ${END} ]; -do - SERVER_UP=$( - curl -s myapp:8800/server/status | \ - python ./parse_seedsync_status.py - ) - if [[ "${SERVER_UP}" == 'True' ]]; then - break - fi - echo "E2E Test is waiting for Seedsync server to come up..." - sleep 1 -done - - -if [[ "${SERVER_UP}" == 'True' ]]; then - echo "${green}E2E Test detected that Seedsync server is UP${reset}" - node_modules/protractor/bin/protractor tmp/conf.js -else - echo "${red}E2E Test failed to detect Seedsync server${reset}" - exit 1 -fi diff --git a/src/docker/test/e2e/urls.ts b/src/docker/test/e2e/urls.ts deleted file mode 100644 index d60c824c..00000000 --- a/src/docker/test/e2e/urls.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class Urls { - static readonly APP_BASE_URL = "http://myapp:8800/"; - static readonly SELENIUM_ADDRESS = "http://chrome:4444/wd/hub"; -} \ No newline at end of file diff --git a/src/e2e-playwright/.gitignore b/src/e2e-playwright/.gitignore new file mode 100644 index 00000000..db34c9db --- /dev/null +++ b/src/e2e-playwright/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +test-results/ diff --git a/src/e2e-playwright/package.json b/src/e2e-playwright/package.json new file mode 100644 index 00000000..943aab54 --- /dev/null +++ b/src/e2e-playwright/package.json @@ -0,0 +1,12 @@ +{ + "name": "seedsync-e2e", + "private": true, + "scripts": { + "test": "npx playwright test", + "test:headed": "npx playwright test --headed", + "test:report": "npx playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.52.0" + } +} diff --git a/src/e2e-playwright/playwright.config.ts b/src/e2e-playwright/playwright.config.ts new file mode 100644 index 00000000..91a67c63 --- /dev/null +++ b/src/e2e-playwright/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: false, // tests share a Docker container per file + retries: 0, + reporter: [["html", { open: "never" }], ["list"]], + use: { + baseURL: process.env.BASE_URL || "http://localhost:8800", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], +}); diff --git a/src/e2e-playwright/tests/about.spec.ts b/src/e2e-playwright/tests/about.spec.ts new file mode 100644 index 00000000..0af041cc --- /dev/null +++ b/src/e2e-playwright/tests/about.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "./fixtures"; +import { AboutPage } from "./pages/about.page"; + +test.describe("About Page", () => { + let aboutPage: AboutPage; + + test.beforeEach(async ({ page }) => { + aboutPage = new AboutPage(page); + await aboutPage.goto(); + }); + + test("version number is displayed and matches format vX.Y.Z", async () => { + await expect(aboutPage.versionText).toBeVisible(); + const versionContent = await aboutPage.versionText.textContent(); + expect(versionContent).toMatch(/v\d+\.\d+\.\d+/); + }); + + test("GitHub link is present and points to correct URL", async () => { + await expect(aboutPage.githubLink).toBeVisible(); + const href = await aboutPage.githubLink.getAttribute("href"); + expect(href).toContain("github.com/nitrobass24/seedsync"); + }); + + test('page renders with app name "SeedSync"', async ({ page }) => { + const appName = page.locator("text=SeedSync"); + await expect(appName.first()).toBeVisible(); + }); +}); diff --git a/src/e2e-playwright/tests/autoqueue.spec.ts b/src/e2e-playwright/tests/autoqueue.spec.ts new file mode 100644 index 00000000..6f752224 --- /dev/null +++ b/src/e2e-playwright/tests/autoqueue.spec.ts @@ -0,0 +1,279 @@ +import { test, expect } from "./fixtures"; +import { AutoQueuePage } from "./pages/autoqueue.page"; + +test.describe("AutoQueue Page", () => { + let autoqueue: AutoQueuePage; + let savedEnabled: string; + let savedPatternsOnly: string; + + test.beforeEach(async ({ apiGet, apiSetConfig }) => { + // Save current autoqueue config + const config = await apiGet("/server/config/get"); + savedEnabled = String(config.autoqueue?.enabled ?? "false"); + savedPatternsOnly = String(config.autoqueue?.patterns_only ?? "false"); + }); + + test.afterEach(async ({ apiSetConfig, apiGet }) => { + // Clean up any test patterns + try { + const data = await apiGet("/server/autoqueue/get"); + const patterns: { pattern: string }[] = Array.isArray(data) + ? data + : data.patterns || []; + for (const p of patterns) { + if (p.pattern.startsWith("test-")) { + await apiGet( + `/server/autoqueue/remove/${encodeURIComponent(p.pattern)}` + ); + } + } + } catch { + // Ignore cleanup errors + } + + // Restore autoqueue config + await apiSetConfig("autoqueue", "enabled", savedEnabled); + await apiSetConfig("autoqueue", "patterns_only", savedPatternsOnly); + }); + + test("when autoqueue disabled: page shows disabled message", async ({ + page, + apiSetConfig, + waitForStream, + }) => { + await apiSetConfig("autoqueue", "enabled", "false"); + await apiSetConfig("autoqueue", "patterns_only", "false"); + + autoqueue = new AutoQueuePage(page); + await autoqueue.goto(); + await waitForStream(page); + + const disabledMsg = page.locator("text=/disabled/i"); + await expect(disabledMsg).toBeVisible(); + }); + + test("when autoqueue enabled but patterns_only disabled: shows all files message", async ({ + page, + apiSetConfig, + waitForStream, + }) => { + await apiSetConfig("autoqueue", "enabled", "true"); + await apiSetConfig("autoqueue", "patterns_only", "false"); + + autoqueue = new AutoQueuePage(page); + await autoqueue.goto(); + await waitForStream(page); + + const allFilesMsg = page.locator("text=/all files/i"); + await expect(allFilesMsg).toBeVisible(); + }); + + test("when autoqueue enabled + patterns_only: shows active UI with input", async ({ + page, + apiSetConfig, + waitForStream, + }) => { + await apiSetConfig("autoqueue", "enabled", "true"); + await apiSetConfig("autoqueue", "patterns_only", "true"); + + autoqueue = new AutoQueuePage(page); + await autoqueue.goto(); + await waitForStream(page); + + await expect(autoqueue.patternInput).toBeVisible(); + await expect(autoqueue.addButton).toBeVisible(); + }); + + test("add pattern via input + button click", async ({ + page, + apiSetConfig, + apiGet, + waitForStream, + }) => { + await apiSetConfig("autoqueue", "enabled", "true"); + await apiSetConfig("autoqueue", "patterns_only", "true"); + + autoqueue = new AutoQueuePage(page); + await autoqueue.goto(); + await waitForStream(page); + + const testPattern = `test-btn-${Date.now()}`; + await autoqueue.addPattern(testPattern); + + // Poll the API until the pattern appears + await expect + .poll( + async () => { + const data = await apiGet("/server/autoqueue/get"); + const raw = Array.isArray(data) ? data : data.patterns || []; + // API returns [{pattern: "..."}] objects + const patterns: string[] = raw.map((p: any) => + typeof p === "string" ? p : p.pattern + ); + return patterns; + }, + { timeout: 5000 } + ) + .toContain(testPattern); + + // Cleanup: remove the pattern + const patternItem = autoqueue.getPatternByText(testPattern); + const removeBtn = autoqueue.getRemoveButton(patternItem); + if (await removeBtn.isVisible().catch(() => false)) { + await removeBtn.click(); + } + }); + + test("add pattern via input + Enter key", async ({ + page, + apiSetConfig, + apiGet, + waitForStream, + }) => { + await apiSetConfig("autoqueue", "enabled", "true"); + await apiSetConfig("autoqueue", "patterns_only", "true"); + + autoqueue = new AutoQueuePage(page); + await autoqueue.goto(); + await waitForStream(page); + + const testPattern = `test-enter-${Date.now()}`; + await autoqueue.patternInput.fill(testPattern); + await autoqueue.patternInput.press("Enter"); + + // Poll the API until the pattern appears + await expect + .poll( + async () => { + const data = await apiGet("/server/autoqueue/get"); + const raw = Array.isArray(data) ? data : data.patterns || []; + // API returns [{pattern: "..."}] objects + const patterns: string[] = raw.map((p: any) => + typeof p === "string" ? p : p.pattern + ); + return patterns; + }, + { timeout: 5000 } + ) + .toContain(testPattern); + + // Cleanup + const patternItem = autoqueue.getPatternByText(testPattern); + const removeBtn = autoqueue.getRemoveButton(patternItem); + if (await removeBtn.isVisible().catch(() => false)) { + await removeBtn.click(); + } + }); + + test("remove pattern via remove button", async ({ + page, + apiSetConfig, + apiGet, + waitForStream, + }) => { + await apiSetConfig("autoqueue", "enabled", "true"); + await apiSetConfig("autoqueue", "patterns_only", "true"); + + autoqueue = new AutoQueuePage(page); + await autoqueue.goto(); + await waitForStream(page); + + // Add a pattern first + const testPattern = `test-remove-${Date.now()}`; + await autoqueue.addPattern(testPattern); + + // Poll until it appears in the API + await expect + .poll( + async () => { + const data = await apiGet("/server/autoqueue/get"); + const raw = Array.isArray(data) ? data : data.patterns || []; + // API returns [{pattern: "..."}] objects + const patterns: string[] = raw.map((p: any) => + typeof p === "string" ? p : p.pattern + ); + return patterns; + }, + { timeout: 5000 } + ) + .toContain(testPattern); + + // Verify it's visible in the UI + const patternItem = autoqueue.getPatternByText(testPattern); + await expect(patternItem).toBeVisible(); + + // Remove it + const removeBtn = autoqueue.getRemoveButton(patternItem); + await removeBtn.click(); + + // Poll the API until it's gone + await expect + .poll( + async () => { + const data = await apiGet("/server/autoqueue/get"); + const raw = Array.isArray(data) ? data : data.patterns || []; + // API returns [{pattern: "..."}] objects + const patterns: string[] = raw.map((p: any) => + typeof p === "string" ? p : p.pattern + ); + return patterns; + }, + { timeout: 5000 } + ) + .not.toContain(testPattern); + }); + + test("duplicate pattern shows error", async ({ + page, + apiSetConfig, + waitForStream, + }) => { + await apiSetConfig("autoqueue", "enabled", "true"); + await apiSetConfig("autoqueue", "patterns_only", "true"); + + autoqueue = new AutoQueuePage(page); + await autoqueue.goto(); + await waitForStream(page); + + const testPattern = `test-dup-${Date.now()}`; + await autoqueue.addPattern(testPattern); + + // Try adding the same pattern again + await autoqueue.addPattern(testPattern); + + const error = autoqueue.getErrorMessage(); + await expect(error).toBeVisible(); + + // Cleanup + const patternItem = autoqueue.getPatternByText(testPattern); + const removeBtn = autoqueue.getRemoveButton(patternItem); + if (await removeBtn.isVisible().catch(() => false)) { + await removeBtn.click(); + } + }); + + test("added pattern is visible in list", async ({ + page, + apiSetConfig, + waitForStream, + }) => { + await apiSetConfig("autoqueue", "enabled", "true"); + await apiSetConfig("autoqueue", "patterns_only", "true"); + + autoqueue = new AutoQueuePage(page); + await autoqueue.goto(); + await waitForStream(page); + + const testPattern = `test-visible-${Date.now()}`; + await autoqueue.addPattern(testPattern); + + const patternItem = autoqueue.getPatternByText(testPattern); + await expect(patternItem).toBeVisible(); + + // Cleanup + const removeBtn = autoqueue.getRemoveButton(patternItem); + if (await removeBtn.isVisible().catch(() => false)) { + await removeBtn.click(); + } + }); +}); diff --git a/src/e2e-playwright/tests/dashboard.spec.ts b/src/e2e-playwright/tests/dashboard.spec.ts new file mode 100644 index 00000000..5bea26bf --- /dev/null +++ b/src/e2e-playwright/tests/dashboard.spec.ts @@ -0,0 +1,181 @@ +import { test, expect } from "./fixtures"; +import { DashboardPage } from "./pages/dashboard.page"; + +test.describe("Dashboard Page", () => { + let dashboard: DashboardPage; + + test.beforeEach(async ({ page, waitForStream }) => { + dashboard = new DashboardPage(page); + await dashboard.goto(); + await waitForStream(page); + }); + + test("page loads without errors", async ({ page }) => { + // No uncaught exceptions and the URL is correct + expect(page.url()).toContain("/dashboard"); + }); + + test("file list container is visible", async () => { + await expect(dashboard.fileList).toBeVisible(); + }); + + test("name filter input is present and functional", async () => { + await expect(dashboard.nameFilter).toBeVisible(); + await dashboard.nameFilter.fill("test-filter-text"); + await expect(dashboard.nameFilter).toHaveValue("test-filter-text"); + }); + + test("status filter dropdown is present with options", async () => { + await expect(dashboard.statusFilterButton).toBeVisible(); + // Click to open the dropdown + await dashboard.statusFilterButton.click(); + const items = dashboard.statusFilterMenu.locator(".dropdown-item"); + const count = await items.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("sort dropdown is present with Name A-Z, Name Z-A, Status options", async () => { + await expect(dashboard.sortDropdownButton).toBeVisible(); + // Click to open the dropdown + await dashboard.sortDropdownButton.click(); + const items = dashboard.sortDropdownMenu.locator(".dropdown-item"); + const texts: string[] = []; + const count = await items.count(); + for (let i = 0; i < count; i++) { + const text = await items.nth(i).textContent(); + if (text) texts.push(text.trim()); + } + // The HTML uses → which renders as arrow: "Name A→Z", "Name Z→A" + expect(texts.some((t) => /name.*a.*z/i.test(t))).toBe(true); + expect(texts.some((t) => /name.*z.*a/i.test(t))).toBe(true); + expect(texts.some((t) => /status/i.test(t))).toBe(true); + }); + + test("details toggle button is present and clickable", async () => { + await expect(dashboard.detailsToggle).toBeVisible(); + await dashboard.detailsToggle.click(); + // No error means the button is functional + }); + + test("checkbox on file row can be toggled", async () => { + const rows = dashboard.getFileRows(); + const count = await rows.count(); + test.skip(count === 0, "No files present to test checkboxes"); + + const checkbox = dashboard.getCheckbox(rows.first()); + await expect(checkbox).toBeVisible(); + await checkbox.check(); + await expect(checkbox).toBeChecked(); + await checkbox.uncheck(); + await expect(checkbox).not.toBeChecked(); + }); +}); + +test.describe("Dashboard with files", () => { + let dashboard: DashboardPage; + let fileCount: number; + + test.beforeEach(async ({ page, waitForStream }) => { + dashboard = new DashboardPage(page); + await dashboard.goto(); + await waitForStream(page); + fileCount = await dashboard.getFileRows().count(); + }); + + test("file rows show file name", async () => { + test.skip(fileCount === 0, "No files present on the remote seedbox"); + + const rows = dashboard.getFileRows(); + const firstName = rows.first().locator(".name .text .title"); + await expect(firstName).toBeVisible(); + const text = await firstName.textContent(); + expect(text?.trim().length).toBeGreaterThan(0); + }); + + test("file rows show status icon", async () => { + test.skip(fileCount === 0, "No files present on the remote seedbox"); + + const rows = dashboard.getFileRows(); + const icon = rows.first().locator(".status img"); + await expect(icon.first()).toBeVisible(); + }); + + test("clicking a file row shows action buttons", async () => { + test.skip(fileCount === 0, "No files present on the remote seedbox"); + + const row = dashboard.getFileRows().first(); + await row.click(); + const actionButtons = row.locator(".actions button"); + const btnCount = await actionButtons.count(); + expect(btnCount).toBeGreaterThanOrEqual(1); + }); + + test("clicking a file row renders the action area", async () => { + test.skip(fileCount === 0, "No files present on the remote seedbox"); + + const row = dashboard.getFileRows().first(); + await row.click(); + const actionArea = row.locator(".actions"); + await expect(actionArea).toBeVisible(); + }); +}); + +test.describe("Dashboard bulk actions", () => { + let dashboard: DashboardPage; + let fileCount: number; + + test.beforeEach(async ({ page, waitForStream }) => { + dashboard = new DashboardPage(page); + await dashboard.goto(); + await waitForStream(page); + fileCount = await dashboard.getFileRows().count(); + }); + + test("selecting multiple files shows bulk action bar", async () => { + test.skip(fileCount < 2, "Need at least 2 files for bulk selection"); + + const rows = dashboard.getFileRows(); + await dashboard.getCheckbox(rows.nth(0)).check(); + await dashboard.getCheckbox(rows.nth(1)).check(); + await expect(dashboard.bulkActionBar).toBeVisible(); + }); + + test("bulk action bar shows selected count", async () => { + test.skip(fileCount < 2, "Need at least 2 files for bulk selection"); + + const rows = dashboard.getFileRows(); + await dashboard.getCheckbox(rows.nth(0)).check(); + await dashboard.getCheckbox(rows.nth(1)).check(); + const countEl = dashboard.getBulkSelectedCount(); + await expect(countEl).toBeVisible(); + await expect(countEl).toContainText("2"); + }); + + test("bulk action bar has Queue, Stop, Delete Local, Delete Remote buttons", async () => { + test.skip(fileCount < 2, "Need at least 2 files for bulk selection"); + + const rows = dashboard.getFileRows(); + await dashboard.getCheckbox(rows.nth(0)).check(); + await dashboard.getCheckbox(rows.nth(1)).check(); + + await expect(dashboard.getBulkButton("queue")).toBeVisible(); + await expect(dashboard.getBulkButton("stop")).toBeVisible(); + await expect(dashboard.getBulkButton("delete local")).toBeVisible(); + await expect(dashboard.getBulkButton("delete remote")).toBeVisible(); + }); + + test("clear button in bulk bar deselects all", async () => { + test.skip(fileCount < 2, "Need at least 2 files for bulk selection"); + + const rows = dashboard.getFileRows(); + await dashboard.getCheckbox(rows.nth(0)).check(); + await dashboard.getCheckbox(rows.nth(1)).check(); + await expect(dashboard.bulkActionBar).toBeVisible(); + + const clearBtn = dashboard.getBulkButton("clear"); + await clearBtn.click(); + + await expect(dashboard.getCheckbox(rows.nth(0))).not.toBeChecked(); + await expect(dashboard.getCheckbox(rows.nth(1))).not.toBeChecked(); + }); +}); diff --git a/src/e2e-playwright/tests/fixtures.ts b/src/e2e-playwright/tests/fixtures.ts new file mode 100644 index 00000000..96d5809d --- /dev/null +++ b/src/e2e-playwright/tests/fixtures.ts @@ -0,0 +1,66 @@ +import { test as base, expect, type Page } from "@playwright/test"; + +/** + * Extended test fixture that provides helpers for interacting with the + * SeedSync backend. Tests run against a live Docker container at BASE_URL. + * + * The container must be started before running tests: + * make run (or docker compose -f docker-compose.dev.yml up -d) + */ +export const test = base.extend<{ + /** The base URL of the SeedSync instance */ + appUrl: string; + /** Helper to wait for the SSE stream to connect and deliver initial data */ + waitForStream: (page: Page) => Promise; + /** Helper to GET a JSON API endpoint */ + apiGet: (path: string) => Promise; + /** Helper to set a config value via the API */ + apiSetConfig: (section: string, key: string, value: string) => Promise; + /** Helper to make API fetch requests with proper CSRF Origin header */ + apiFetch: (path: string, init?: RequestInit) => Promise; +}>({ + appUrl: async ({ baseURL }, use) => { + await use(baseURL || "http://localhost:8800"); + }, + + waitForStream: async ({}, use) => { + await use(async (page: Page) => { + // Wait for the Angular app to render by checking for sidebar nav links. + // These are rendered after the app bootstraps and the SSE stream connects. + await page.waitForSelector('a[href="/dashboard"]', { timeout: 15_000 }); + }); + }, + + apiGet: async ({ appUrl }, use) => { + await use(async (path: string) => { + const res = await fetch(`${appUrl}${path}`, { + headers: { Origin: appUrl }, + }); + if (!res.ok) throw new Error(`API ${path} returned ${res.status}`); + return res.json(); + }); + }, + + apiSetConfig: async ({ appUrl }, use) => { + await use(async (section: string, key: string, value: string) => { + const encoded = encodeURIComponent(encodeURIComponent(value || "__empty__")); + const res = await fetch( + `${appUrl}/server/config/set/${section}/${key}/${encoded}` + ); + if (!res.ok) throw new Error(`Config set failed: ${res.status}`); + }); + }, + + apiFetch: async ({ appUrl }, use) => { + await use(async (path: string, init?: RequestInit) => { + const headers = new Headers(init?.headers); + // Always include Origin header for CSRF validation + if (!headers.has("Origin")) { + headers.set("Origin", appUrl); + } + return fetch(`${appUrl}${path}`, { ...init, headers }); + }); + }, +}); + +export { expect }; diff --git a/src/e2e-playwright/tests/logs.spec.ts b/src/e2e-playwright/tests/logs.spec.ts new file mode 100644 index 00000000..283d829d --- /dev/null +++ b/src/e2e-playwright/tests/logs.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from "./fixtures"; +import { LogsPage } from "./pages/logs.page"; + +test.describe("Logs Page", () => { + let logs: LogsPage; + + test.beforeEach(async ({ page, waitForStream }) => { + logs = new LogsPage(page); + await logs.goto(); + await waitForStream(page); + }); + + test("page loads without errors", async ({ page }) => { + expect(page.url()).toContain("/logs"); + }); + + test("search input is present", async () => { + await expect(logs.searchInput).toBeVisible(); + }); + + test("level filter dropdown has expected options", async () => { + await expect(logs.levelFilter).toBeVisible(); + const options = logs.levelFilter.locator("option"); + const texts: string[] = []; + const count = await options.count(); + for (let i = 0; i < count; i++) { + const text = await options.nth(i).textContent(); + if (text) texts.push(text.trim().toUpperCase()); + } + + const expected = ["ALL LEVELS", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]; + for (const level of expected) { + expect(texts.some((t) => t.includes(level))).toBe(true); + } + }); + + test("log filters section is visible", async () => { + await expect(logs.logFilters).toBeVisible(); + }); + + test("if log records exist: records show timestamp, level, and message", async () => { + const records = logs.getLogRecords(); + const count = await records.count(); + test.skip(count === 0, "No log records present"); + + const firstRecord = records.first(); + const text = await firstRecord.textContent(); + expect(text).toBeTruthy(); + + // Expect a timestamp-like pattern (e.g., HH:MM:SS or YYYY-MM-DD) + expect(text).toMatch(/\d{2}[:\-]\d{2}/); + + // Expect a log level keyword + expect(text).toMatch(/DEBUG|INFO|WARNING|ERROR|CRITICAL/i); + }); + + test("search filter narrows displayed records", async () => { + const records = logs.getLogRecords(); + const count = await records.count(); + test.skip(count === 0, "No log records to search through"); + + // Get text from the first record to use as a valid search term + const firstText = await records.first().textContent(); + const searchTerm = firstText?.trim().split(/\s+/).filter(Boolean).pop() || ""; + test.skip(searchTerm === "", "Could not derive a non-empty search term from log records"); + + // Type a term that exists + await logs.searchInput.fill(searchTerm); + await expect + .poll(async () => await records.count(), { timeout: 5000 }) + .toBeLessThanOrEqual(count); + + // Type a nonsense term that should match nothing + await logs.searchInput.fill("zzz_no_match_xyz_99999"); + await expect(records).toHaveCount(0, { timeout: 5000 }); + }); +}); diff --git a/src/e2e-playwright/tests/navigation.spec.ts b/src/e2e-playwright/tests/navigation.spec.ts new file mode 100644 index 00000000..9afe7613 --- /dev/null +++ b/src/e2e-playwright/tests/navigation.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from "./fixtures"; +import { SidebarPage } from "./pages/sidebar.page"; + +test.describe("Sidebar Navigation", () => { + let sidebar: SidebarPage; + + test.beforeEach(async ({ page }) => { + sidebar = new SidebarPage(page); + }); + + test("root / redirects to /dashboard", async ({ page }) => { + await page.goto("/"); + await page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + expect(page.url()).toContain("/dashboard"); + }); + + test("clicking Dashboard link navigates to /dashboard", async ({ page }) => { + await page.goto("/about"); + await page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + await sidebar.navigateTo(sidebar.dashboardLink); + expect(page.url()).toContain("/dashboard"); + }); + + test("clicking Settings link navigates to /settings", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + await sidebar.navigateTo(sidebar.settingsLink); + expect(page.url()).toContain("/settings"); + }); + + test("clicking AutoQueue link navigates to /autoqueue", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + await sidebar.navigateTo(sidebar.autoqueueLink); + expect(page.url()).toContain("/autoqueue"); + }); + + test("clicking Logs link navigates to /logs", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + await sidebar.navigateTo(sidebar.logsLink); + expect(page.url()).toContain("/logs"); + }); + + test("clicking About link navigates to /about", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + await sidebar.navigateTo(sidebar.aboutLink); + expect(page.url()).toContain("/about"); + }); + + test("active link is highlighted after navigation", async ({ page }) => { + await page.goto("/settings"); + await page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + const activeLink = sidebar.getActiveLink(); + await expect(activeLink).toBeVisible(); + await expect(activeLink).toHaveAttribute("href", "/settings"); + }); + + test("all 5 nav links are visible in sidebar", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + await expect(sidebar.dashboardLink).toBeVisible(); + await expect(sidebar.settingsLink).toBeVisible(); + await expect(sidebar.autoqueueLink).toBeVisible(); + await expect(sidebar.logsLink).toBeVisible(); + await expect(sidebar.aboutLink).toBeVisible(); + }); +}); diff --git a/src/e2e-playwright/tests/pages/about.page.ts b/src/e2e-playwright/tests/pages/about.page.ts new file mode 100644 index 00000000..e4d67548 --- /dev/null +++ b/src/e2e-playwright/tests/pages/about.page.ts @@ -0,0 +1,19 @@ +import { type Page, type Locator } from "@playwright/test"; + +export class AboutPage { + readonly page: Page; + readonly versionText: Locator; + readonly githubLink: Locator; + + constructor(page: Page) { + this.page = page; + this.versionText = page.locator("text=/v\\d+\\.\\d+\\.\\d+/"); + this.githubLink = page.locator('a[href*="github.com/nitrobass24/seedsync"]'); + } + + async goto() { + await this.page.goto("/about"); + await this.page.waitForLoadState("domcontentloaded"); + await this.page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + } +} diff --git a/src/e2e-playwright/tests/pages/autoqueue.page.ts b/src/e2e-playwright/tests/pages/autoqueue.page.ts new file mode 100644 index 00000000..cd782574 --- /dev/null +++ b/src/e2e-playwright/tests/pages/autoqueue.page.ts @@ -0,0 +1,59 @@ +import { type Page, type Locator } from "@playwright/test"; + +export class AutoQueuePage { + readonly page: Page; + readonly patternInput: Locator; + readonly addButton: Locator; + readonly patternList: Locator; + readonly disabledMessage: Locator; + + constructor(page: Page) { + this.page = page; + this.patternInput = page.locator("input[type='search']"); + this.addButton = page.locator("#add-pattern .button"); + this.patternList = page.locator("#controls"); + this.disabledMessage = page.locator("text=/disabled|all files/i"); + } + + async goto() { + await this.page.goto("/autoqueue"); + await this.page.waitForLoadState("domcontentloaded"); + await this.page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + } + + getPatternItems() { + return this.page.locator("#controls .pattern"); + } + + getPatternByText(pattern: string) { + return this.page.locator("[class*='pattern']", { hasText: pattern }); + } + + getRemoveButton(patternItem: Locator) { + return patternItem.locator(".button"); + } + + async addPattern(pattern: string) { + // Wait for input to be enabled (SSE stream must have delivered config) + await this.patternInput.waitFor({ state: "visible", timeout: 10_000 }); + // Wait until the input is not disabled (config has been received) + await this.page.waitForFunction( + () => { + const el = document.querySelector("input[type='search']") as HTMLInputElement; + return el && !el.disabled; + }, + { timeout: 10_000 } + ); + await this.patternInput.fill(pattern); + // Small delay for Angular change detection to process the input value + await this.page.waitForTimeout(200); + // Click with force since it's a div, not a native button + await this.addButton.click({ force: true }); + } + + getErrorMessage() { + return this.page.locator(".alert-danger.alert-dismissible", { + hasText: /already exists|error/i, + }); + } +} diff --git a/src/e2e-playwright/tests/pages/dashboard.page.ts b/src/e2e-playwright/tests/pages/dashboard.page.ts new file mode 100644 index 00000000..ecd94c18 --- /dev/null +++ b/src/e2e-playwright/tests/pages/dashboard.page.ts @@ -0,0 +1,79 @@ +import { type Page, type Locator } from "@playwright/test"; + +export class DashboardPage { + readonly page: Page; + readonly fileList: Locator; + readonly nameFilter: Locator; + readonly statusFilterButton: Locator; + readonly statusFilterMenu: Locator; + readonly sortDropdownButton: Locator; + readonly sortDropdownMenu: Locator; + readonly detailsToggle: Locator; + readonly bulkActionBar: Locator; + + constructor(page: Page) { + this.page = page; + this.fileList = page.locator("#file-list"); + this.nameFilter = page.locator( + '#filter-search input[type="search"]' + ); + this.statusFilterButton = page.locator( + "#filter-status .dropdown-toggle" + ); + this.statusFilterMenu = page.locator("#filter-status .dropdown-menu"); + this.sortDropdownButton = page.locator( + "#sort-status .dropdown-toggle" + ); + this.sortDropdownMenu = page.locator("#sort-status .dropdown-menu"); + this.detailsToggle = page.locator("#toggle-details"); + this.bulkActionBar = page.locator("app-bulk-action-bar"); + } + + async goto() { + await this.page.goto("/dashboard"); + await this.page.waitForLoadState("domcontentloaded"); + await this.page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + } + + getFileRows() { + return this.page.locator("app-file"); + } + + getFileByName(name: string) { + return this.page.locator("app-file", { + hasText: name, + }); + } + + async getFileNames(): Promise { + const rows = this.getFileRows(); + const count = await rows.count(); + const names: string[] = []; + for (let i = 0; i < count; i++) { + const nameEl = rows.nth(i).locator(".name .text .title"); + const text = await nameEl.textContent(); + if (text) names.push(text.trim()); + } + return names; + } + + getActionButton(fileRow: Locator, action: string) { + return fileRow.locator(".actions button", { + hasText: new RegExp(action, "i"), + }); + } + + getCheckbox(fileRow: Locator) { + return fileRow.locator(".checkbox input[type='checkbox']"); + } + + getBulkButton(action: string) { + return this.bulkActionBar.locator("button", { + hasText: new RegExp(action, "i"), + }); + } + + getBulkSelectedCount() { + return this.bulkActionBar.locator(".count"); + } +} diff --git a/src/e2e-playwright/tests/pages/logs.page.ts b/src/e2e-playwright/tests/pages/logs.page.ts new file mode 100644 index 00000000..690a8878 --- /dev/null +++ b/src/e2e-playwright/tests/pages/logs.page.ts @@ -0,0 +1,33 @@ +import { type Page, type Locator } from "@playwright/test"; + +export class LogsPage { + readonly page: Page; + readonly searchInput: Locator; + readonly levelFilter: Locator; + readonly logFilters: Locator; + readonly scrollToTop: Locator; + readonly scrollToBottom: Locator; + + constructor(page: Page) { + this.page = page; + this.searchInput = page.locator("#log-search"); + this.levelFilter = page.locator("#log-level"); + this.logFilters = page.locator(".log-filters"); + this.scrollToTop = page.locator("#btn-scroll-top"); + this.scrollToBottom = page.locator("#btn-scroll-bottom"); + } + + async goto() { + await this.page.goto("/logs"); + await this.page.waitForLoadState("domcontentloaded"); + await this.page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + } + + getLogRecords() { + return this.page.locator("#logs .record"); + } + + getLogRecordsByLevel(level: string) { + return this.page.locator(`#logs .record.${level.toLowerCase()}`); + } +} diff --git a/src/e2e-playwright/tests/pages/path-pairs.page.ts b/src/e2e-playwright/tests/pages/path-pairs.page.ts new file mode 100644 index 00000000..04f84a60 --- /dev/null +++ b/src/e2e-playwright/tests/pages/path-pairs.page.ts @@ -0,0 +1,78 @@ +import { type Page, type Locator } from "@playwright/test"; + +export class PathPairsPage { + readonly page: Page; + readonly addButton: Locator; + readonly pairsList: Locator; + readonly emptyMessage: Locator; + + constructor(page: Page) { + this.page = page; + this.addButton = page.locator("button.btn-add"); + this.pairsList = page.locator(".path-pairs"); + this.emptyMessage = page.locator(".empty-state"); + } + + async goto() { + await this.page.goto("/settings"); + await this.page.waitForURL("**/settings", { timeout: 10_000 }); + await this.page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + } + + getPairRows() { + return this.pairsList.locator(".pair-row"); + } + + getPairByName(name: string) { + // Use exact match on .pair-name to avoid substring collisions + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return this.pairsList.locator(".pair-row").filter({ + has: this.page.locator(".pair-name", { hasText: new RegExp(`^${escaped}$`) }), + }); + } + + /** Fill the add/edit form fields */ + async fillForm(fields: { + name?: string; + remotePath?: string; + localPath?: string; + }) { + const form = this.page.locator(".pair-form"); + if (fields.name !== undefined) { + const nameInput = form.locator('label:has-text("Name") input'); + await nameInput.fill(fields.name); + } + if (fields.remotePath !== undefined) { + const remoteInput = form.locator('label:has-text("Remote Path") input'); + await remoteInput.fill(fields.remotePath); + } + if (fields.localPath !== undefined) { + const localInput = form.locator('label:has-text("Local Path") input'); + await localInput.fill(fields.localPath); + } + } + + async clickSave() { + await this.page.locator("button.btn-save").click(); + } + + async clickCancel() { + await this.page.locator("button.btn-cancel").click(); + } + + getErrorMessage() { + return this.page.locator(".error-message"); + } + + getDeleteButton(pairRow: Locator) { + return pairRow.locator("button.btn-delete"); + } + + getEditButton(pairRow: Locator) { + return pairRow.locator("button.btn-edit"); + } + + getEnabledToggle(pairRow: Locator) { + return pairRow.locator('label:has-text("Enabled") input[type="checkbox"]'); + } +} diff --git a/src/e2e-playwright/tests/pages/settings.page.ts b/src/e2e-playwright/tests/pages/settings.page.ts new file mode 100644 index 00000000..3450ffba --- /dev/null +++ b/src/e2e-playwright/tests/pages/settings.page.ts @@ -0,0 +1,69 @@ +import { type Page, type Locator } from "@playwright/test"; + +export class SettingsPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async goto() { + await this.page.goto("/settings"); + await this.page.waitForLoadState("domcontentloaded"); + await this.page.waitForSelector('a[href="/dashboard"]', { timeout: 10_000 }); + } + + /** Get a settings section card by its header text */ + getSection(headerText: string) { + // Find the card that contains a matching h3 header + // Use XPath parent to go from h3 back to its .card container + return this.page + .locator("h3.card-header", { hasText: headerText }) + .first() + .locator("xpath=.."); + } + + /** Get a text/password input by its label */ + getTextInput(label: string) { + return this.page + .locator("app-option", { hasText: label }) + .locator("input[type='text'], input[type='password']"); + } + + /** Get a checkbox by its label */ + getCheckbox(label: string) { + return this.page + .locator("app-option", { hasText: label }) + .locator("input[type='checkbox']"); + } + + /** Get a select dropdown by its label */ + getSelect(label: string) { + return this.page + .locator("app-option", { hasText: label }) + .locator("select"); + } + + /** Expand a collapsed section (e.g., Advanced LFTP) */ + async expandSection(headerText: string) { + const header = this.page.locator("h3.card-header.collapsible-header", { + hasText: headerText, + }); + const card = header.locator("xpath=.."); + const collapseBody = card.locator("app-option"); + + // If the body content is not visible, click the header to expand + if ((await collapseBody.count()) === 0) { + await header.click(); + // Wait for Angular @if to render the content + await collapseBody.first().waitFor({ state: "visible", timeout: 3000 }); + } + } + + /** Get the restart notification if visible */ + getRestartNotification() { + return this.page.locator(".alert", { + hasText: /restart/i, + }); + } +} diff --git a/src/e2e-playwright/tests/pages/sidebar.page.ts b/src/e2e-playwright/tests/pages/sidebar.page.ts new file mode 100644 index 00000000..132bb92d --- /dev/null +++ b/src/e2e-playwright/tests/pages/sidebar.page.ts @@ -0,0 +1,40 @@ +import { type Page, type Locator } from "@playwright/test"; + +export class SidebarPage { + readonly page: Page; + readonly dashboardLink: Locator; + readonly settingsLink: Locator; + readonly autoqueueLink: Locator; + readonly logsLink: Locator; + readonly aboutLink: Locator; + readonly restartButton: Locator; + readonly themeToggle: Locator; + + constructor(page: Page) { + this.page = page; + this.dashboardLink = page.locator('a[href="/dashboard"]'); + this.settingsLink = page.locator('a[href="/settings"]'); + this.autoqueueLink = page.locator('a[href="/autoqueue"]'); + this.logsLink = page.locator('a[href="/logs"]'); + this.aboutLink = page.locator('a[href="/about"]'); + // Restart and theme toggle are not