From 699e83c49b905ce82d2260ecbed31f1a3c1ea285 Mon Sep 17 00:00:00 2001 From: Joseph Kavanagh Date: Mon, 22 Jun 2026 00:26:56 +0100 Subject: [PATCH 01/12] test(playwright): initial playwright config and tests --- .github/workflows/playwright.yml | 142 ++ .gitignore | 18 +- Makefile | 68 + config.yml.example | 4 +- package-lock.json | 142 +- package.json | 4 +- web/ui/package-lock.json | 136 +- web/ui/react-app/.gitignore | 12 + web/ui/react-app/package.json | 4 +- web/ui/react-app/playwright.config.ts | 99 ++ web/ui/react-app/tests/create-service.spec.ts | 289 ++++ web/ui/react-app/tests/dashboard.spec.ts | 15 + web/ui/react-app/tests/fixtures/service.ts | 527 +++++++ .../tests/fixtures/test-endpoints.ts | 60 + web/ui/react-app/tests/fixtures/validation.ts | 341 +++++ web/ui/react-app/tests/navigation.spec.ts | 75 + ...eation-validation-deployed-version.spec.ts | 200 +++ ...creation-validation-latest-version.spec.ts | 356 +++++ ...service-creation-validation-notify.spec.ts | 1261 +++++++++++++++++ ...ervice-creation-validation-webhook.spec.ts | 183 +++ .../tests/service-creation-validation.spec.ts | 178 +++ .../react-app/tests/service-ordering.spec.ts | 218 +++ .../tests/service-secret-inheritance.spec.ts | 561 ++++++++ web/ui/react-app/tests/webhook.spec.ts | 202 +++ web/ui/react-app/tsconfig.playwright.json | 19 + 25 files changed, 4957 insertions(+), 157 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 web/ui/react-app/.gitignore create mode 100644 web/ui/react-app/playwright.config.ts create mode 100644 web/ui/react-app/tests/create-service.spec.ts create mode 100644 web/ui/react-app/tests/dashboard.spec.ts create mode 100644 web/ui/react-app/tests/fixtures/service.ts create mode 100644 web/ui/react-app/tests/fixtures/test-endpoints.ts create mode 100644 web/ui/react-app/tests/fixtures/validation.ts create mode 100644 web/ui/react-app/tests/navigation.spec.ts create mode 100644 web/ui/react-app/tests/service-creation-validation-deployed-version.spec.ts create mode 100644 web/ui/react-app/tests/service-creation-validation-latest-version.spec.ts create mode 100644 web/ui/react-app/tests/service-creation-validation-notify.spec.ts create mode 100644 web/ui/react-app/tests/service-creation-validation-webhook.spec.ts create mode 100644 web/ui/react-app/tests/service-creation-validation.spec.ts create mode 100644 web/ui/react-app/tests/service-ordering.spec.ts create mode 100644 web/ui/react-app/tests/service-secret-inheritance.spec.ts create mode 100644 web/ui/react-app/tests/webhook.spec.ts create mode 100644 web/ui/react-app/tsconfig.playwright.json diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..d93ec572 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,142 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +permissions: + contents: read + pull-requests: write +concurrency: + group: playwright-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Go 1.x + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + check-latest: true + cache: true + + - name: Use Node.js LTS + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: lts/* + + - name: Build Argus (web + binary) + run: make build + + - name: Start Argus test server + run: make playwright-tests-setup + env: + ARGUS_SERVICE_LATEST_VERSION_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARGUS_SERVICE_LATEST_VERSION_REQUIRE_DOCKER_REGISTRY_HUB_AUTH_USERNAME: ${{ vars.DOCKER_HUB_USERNAME_R }} + ARGUS_SERVICE_LATEST_VERSION_REQUIRE_DOCKER_REGISTRY_HUB_AUTH_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN_R }} + ARGUS_SERVICE_LATEST_VERSION_REQUIRE_DOCKER_REGISTRY_GHCR_AUTH_TOKEN: ${{ secrets.DOCKER_GHCR_TOKEN_R }} + ARGUS_SERVICE_LATEST_VERSION_REQUIRE_DOCKER_REGISTRY_QUAY_AUTH_TOKEN: ${{ secrets.DOCKER_QUAY_TOKEN_R }} + + - name: Install Playwright dependencies + working-directory: web/ui/react-app + run: npm ci + + - name: Type-check Playwright tests + working-directory: web/ui/react-app + run: npm run typecheck:e2e + + - name: Install Playwright Browsers + working-directory: web/ui/react-app + run: npx playwright install --with-deps chromium firefox webkit + + - name: Run Playwright tests + run: make playwright-tests + + - name: Upload test results (screenshots & traces) + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: ${{ !cancelled() }} + with: + name: test-results + path: web/ui/react-app/test-results/ + retention-days: 30 + + - name: Upload Playwright HTML report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: web/ui/react-app/playwright-report/ + retention-days: 30 + + - name: Comment test results on PR + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const fs = require('fs'); + const { owner, repo } = context.repo; + const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`; + + let body; + try { + const { stats } = JSON.parse(fs.readFileSync('web/ui/react-app/results.json', 'utf8')); + const icon = stats.unexpected > 0 ? '❌' : '✅'; + const rows = [ + ['✅ Passed', stats.expected], + ['❌ Failed', stats.unexpected], + ['🔄 Flaky', stats.flaky], + ['⏭️ Skipped', stats.skipped], + ].map(([label, n]) => `| ${label} | ${n} |`).join('\n'); + body = [ + `## ${icon} Playwright Test Results`, + '', + '| | Count |', + '|---|---|', + rows, + '', + `Duration: ${(stats.duration / 1000).toFixed(1)}s`, + '', + `[View screenshots & full report](${runUrl})`, + ].join('\n'); + } catch { + body = `## Playwright Test Results\n\nResults unavailable — [view run](${runUrl}).`; + } + + const marker = ''; + body = `${marker}\n${body}`; + const issue_number = context.payload.pull_request.number; + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find( + (c) => c.user?.type === 'Bot' && c.body?.includes(marker), + ); + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + + + - name: Stop Argus test server + if: ${{ !cancelled() }} + run: make playwright-tests-teardown diff --git a/.gitignore b/.gitignore index be689235..8731ed7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# MacOS +.DS_Store + # Binaries for programs and plugins *.exe *.exe~ @@ -5,11 +8,9 @@ *.so *.dylib -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - # Dependency directories vendor/ +node_modules/ # Output of `make build` /argus @@ -20,12 +21,9 @@ vendor/ # Output of `make build-all` /.build/ +# Output of the go coverage tool +*.out + # Ignore personal files config.yml -argus.db - -# React -node_modules - -# MacOS -.DS_Store \ No newline at end of file +argus.db \ No newline at end of file diff --git a/Makefile b/Makefile index 67eefa37..c91be925 100644 --- a/Makefile +++ b/Makefile @@ -45,3 +45,71 @@ build: web common-build .PHONY: build-all build-all: web-build compress-web build-darwin build-freebsd build-linux build-openbsd build-windows + +PLAYWRIGHT_DIR = $(UI_PATH)/react-app +PLAYWRIGHT_TESTS_DIR = $(PLAYWRIGHT_DIR)/tests +PLAYWRIGHT_CONFIG_FILE = $(PLAYWRIGHT_TESTS_DIR)/playwright-test-config.yml +PLAYWRIGHT_DB_FILE = $(PLAYWRIGHT_TESTS_DIR)/playwright-tests.db +PLAYWRIGHT_PID_FILE = $(PLAYWRIGHT_TESTS_DIR)/playwright-argus.pid +PLAYWRIGHT_LOG_FILE = $(PLAYWRIGHT_TESTS_DIR)/playwright-test.log +# Port the Makefile-managed Argus server listens on. +PLAYWRIGHT_PORT ?= 8080 +# URL the Playwright tests run against; defaults to the managed server. +BASE_URL ?= http://localhost:$(PLAYWRIGHT_PORT) + +# wait_for_argus,: poll the healthcheck endpoint until it answers (max 60s). +define wait_for_argus +i=0; until curl -sf $(1)/api/v1/healthcheck >/dev/null; do \ + i=$$((i+1)); \ + if [ $$i -ge 30 ]; then \ + echo "Argus not reachable at $(1) after 60s" >&2; \ + if [ -f $(PLAYWRIGHT_LOG_FILE) ]; then cat $(PLAYWRIGHT_LOG_FILE) >&2; fi; \ + exit 1; \ + fi; \ + sleep 2; \ +done +endef + +.PHONY: playwright-tests-setup +playwright-tests-setup: + @if [ -n "$(FRESH)" ]; then \ + $(MAKE) playwright-tests-teardown; \ + rm -f ./$(OUTPUT_BINARY); \ + elif [ -f $(PLAYWRIGHT_PID_FILE) ]; then \ + kill $$(cat $(PLAYWRIGHT_PID_FILE)) 2>/dev/null || true; \ + rm -f $(PLAYWRIGHT_PID_FILE); \ + fi + cp config.yml.example $(PLAYWRIGHT_CONFIG_FILE) + @if [ ! -x ./$(OUTPUT_BINARY) ] || [ -n "$(FORCE)" ]; then $(MAKE) build; fi + ./$(OUTPUT_BINARY) \ + -config.file $(PLAYWRIGHT_CONFIG_FILE) \ + -data.database-file $(PLAYWRIGHT_DB_FILE) \ + -web.listen-port $(PLAYWRIGHT_PORT) \ + -log.level DEBUG \ + > $(PLAYWRIGHT_LOG_FILE) 2>&1 & echo $$! > $(PLAYWRIGHT_PID_FILE) + @$(call wait_for_argus,http://localhost:$(PLAYWRIGHT_PORT)) + +.PHONY: playwright-tests-teardown +playwright-tests-teardown: + @if [ -f $(PLAYWRIGHT_PID_FILE) ]; then \ + kill $$(cat $(PLAYWRIGHT_PID_FILE)) 2>/dev/null || true; \ + rm -f $(PLAYWRIGHT_PID_FILE); \ + else \ + echo "No playwright Argus instance found running ($(PLAYWRIGHT_PID_FILE) missing)"; \ + fi + rm -f $(PLAYWRIGHT_CONFIG_FILE) $(PLAYWRIGHT_DB_FILE)* $(PLAYWRIGHT_LOG_FILE) + +.PHONY: playwright-tests +playwright-tests: + @echo "Waiting for Argus to be ready at $(BASE_URL)..." + @$(call wait_for_argus,$(BASE_URL)) + cd $(PLAYWRIGHT_DIR) && \ + BASE_URL=$(BASE_URL) \ + PWTEST_CHILD_PROCESS_TIMEOUT=30000 \ + npx playwright test $(if $(HEADED),--headed,) + +.PHONY: playwright-full +playwright-full: playwright-tests-setup + @$(MAKE) playwright-tests; status=$$?; \ + $(MAKE) playwright-tests-teardown; \ + exit $$status diff --git a/config.yml.example b/config.yml.example index 96b883c4..3daf5780 100644 --- a/config.yml.example +++ b/config.yml.example @@ -1,8 +1,8 @@ service: - release-argus/argus: + release-argus/Argus: latest_version: type: github - url: release-argus/argus + url: release-argus/Argus dashboard: icon: https://raw.githubusercontent.com/release-argus/Argus/master/web/ui/react-app/public/favicon.svg icon_link-to: https://release-argus.io diff --git a/package-lock.json b/package-lock.json index 8a8e6664..bebc69da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.29.4", "license": "ISC", "dependencies": { - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2" + "@commitlint/cli": "^21.1.0", + "@commitlint/config-conventional": "^21.1.0" }, "devDependencies": { "husky": "9.1.7" @@ -40,16 +40,17 @@ } }, "node_modules/@commitlint/cli": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.2.tgz", - "integrity": "sha512-YMmfLbqBg+ZRvvmPhc+cilSQFrh/AgzVgCT1U/OifmUZEwPbvCtA8rN//YNaF9d5eoZphxVMGYtmwA2QgQORgg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.1.0.tgz", + "integrity": "sha512-CVwY6TxGv5naEaWxBdgNHko1xgL95Mb4WcIqp9iik33H0ctVqRv6YtekCntayhEP0T/apuiGvHu5HcCwFuVxEA==", "license": "MIT", "dependencies": { - "@commitlint/format": "^21.0.1", - "@commitlint/lint": "^21.0.2", - "@commitlint/load": "^21.0.2", - "@commitlint/read": "^21.0.2", - "@commitlint/types": "^21.0.1", + "@commitlint/config-conventional": "^21.1.0", + "@commitlint/format": "^21.1.0", + "@commitlint/lint": "^21.1.0", + "@commitlint/load": "^21.1.0", + "@commitlint/read": "^21.1.0", + "@commitlint/types": "^21.1.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, @@ -61,12 +62,12 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.2.tgz", - "integrity": "sha512-P/ZRhryQmkj0Z0dY9FOoRwe3xkwJyyAdtXwt01NT2kuZttcG2CNYp1q5Ci3u+nDT2jcbJRw2kt13Czl1qKNPfg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.1.0.tgz", + "integrity": "sha512-BIFl8xM+3SLy3jrblUC3wmQLCVbLty+++6o859BDCmybVrQdXmIWO+dlkGIbv/M2bBoC55wGuh0zGiw3TPjL1g==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { @@ -74,12 +75,12 @@ } }, "node_modules/@commitlint/config-validator": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.0.1.tgz", - "integrity": "sha512-Zd2UFdndeMMaW2O96HK0tdfT4gOImUvidMpAd/pws2zZ4m1nrAZ/9b/v2JYuE8fs86GpXv9F7LNaIuCIWhY+pA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.1.0.tgz", + "integrity": "sha512-gHczt1xqQSwfNqBmOI3HjejtTljkiBEUneExMmTBLD0WwTC78lAqDvNMyydbySt3DhpH0F9oX7Vvuks6s5XPFw==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "ajv": "^8.11.0" }, "engines": { @@ -87,12 +88,12 @@ } }, "node_modules/@commitlint/ensure": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.0.1.tgz", - "integrity": "sha512-jJ1037967wU7YN/xkv+iRlOBlmaOXPhPO5KQSqya6GyXzBlwuLzELBFao16DVg9dZyqmNrhewzwZ3SAibetHBQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.1.0.tgz", + "integrity": "sha512-/S8Mo3Q1NtQUYDQjDmyQVPxfIwtnxq+guzMOkuGk8OSdwlzanm1WB9wDPIuuzlbMDDnBNbiAuBEUCcCNlfjrTQ==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "es-toolkit": "^1.46.0" }, "engines": { @@ -109,12 +110,12 @@ } }, "node_modules/@commitlint/format": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.0.1.tgz", - "integrity": "sha512-ksmG2+cHGtuDPQQbhBbC4unwm444+6TiPw0d1bKf67hntgZqZ8E0g1MuYKUuyT5IH4IMmXZhKq22/Z3jBvtQIw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.1.0.tgz", + "integrity": "sha512-ySymqKYBfjNrQ5N4W/l1iF2ISW1W7Eu/Oi/wRxlri31N0yjNyzUyUzQwyuZLDzTXIlMs4IZ7hIOfAZx8lO18gA==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "picocolors": "^1.1.1" }, "engines": { @@ -122,12 +123,12 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.0.2.tgz", - "integrity": "sha512-H5z4t8PC9tUsmZ/o+EptM3Nq8sTFtskAShdcqxCoyzklW5eaVT5xbrDAET2uypzir9Vsj4ZZmBtyKjYe2XqgeQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.1.0.tgz", + "integrity": "sha512-RoRh1/YI+fYH+aid5lMQ2UD0vZ3p3Vf1KeUWT1ir3H/p/7T/6SFv1OiXLgLwUT8dP72EVWeEIyOfkiSWLZYVvw==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "semver": "^7.6.0" }, "engines": { @@ -135,30 +136,30 @@ } }, "node_modules/@commitlint/lint": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.0.2.tgz", - "integrity": "sha512-PnUmLYGeGLfW8oVatR9KpNxSHYAnJOEWlMZzfdeFOUq6WUrFx1fGQaWCWJqMoIll/xPM+GdfJV+tKHZVHhl0Fg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.1.0.tgz", + "integrity": "sha512-0DbfVVUjAWBfixW6v7CXXWVxMcj6Ukf/oB7O8NAbouP3jxmqUaC4eVQphxl3B3M0ii3cCQiR3sRAYxICwU2gAA==", "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.0.2", - "@commitlint/parse": "^21.0.2", - "@commitlint/rules": "^21.0.2", - "@commitlint/types": "^21.0.1" + "@commitlint/is-ignored": "^21.1.0", + "@commitlint/parse": "^21.1.0", + "@commitlint/rules": "^21.1.0", + "@commitlint/types": "^21.1.0" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.0.2.tgz", - "integrity": "sha512-lwUE70hN0/qE/ZRROhbaX65ly/FF12DrqfReLCESo37M0OQCFAf2jRS+2tSCSORq+bm4Kdju7qNDj46uc1QzTA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.1.0.tgz", + "integrity": "sha512-juiClVEcoreNB0TNVkseO2EmNcpEs/Yhnmgbnm/hQAKBFRynKwIaoNIljXkx/3yvZcMO0EE8I2XOEI7d5KZG8Q==", "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.0.1", + "@commitlint/config-validator": "^21.1.0", "@commitlint/execute-rule": "^21.0.1", - "@commitlint/resolve-extends": "^21.0.1", - "@commitlint/types": "^21.0.1", + "@commitlint/resolve-extends": "^21.1.0", + "@commitlint/types": "^21.1.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", @@ -179,12 +180,12 @@ } }, "node_modules/@commitlint/parse": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.0.2.tgz", - "integrity": "sha512-QVZJhGHTm+oiuWyEKOCTQ0ZM3mfJ0eGWFeHuj7WzSKEth+UukcCHac9GD8pgdFlg/qGkFWOtyaNd1T8REgagaw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.1.0.tgz", + "integrity": "sha512-HdAqbbjQS8eEtbR74Ysg2VNmbvAfeWLVYMkip/lHibNrtjRsC/97XAYN3/H5P0pEJtDfyTb3iLs8x6y0eu4OYA==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" }, @@ -193,13 +194,13 @@ } }, "node_modules/@commitlint/read": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.0.2.tgz", - "integrity": "sha512-BtsrnLVycSSKf4Q0gMch4giCj5NNlmcbhc8ra5vONgGtP2IjRDo33bEFtr5Pm+2N+5fXGWb2MksWPrspPfdhdw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.1.0.tgz", + "integrity": "sha512-ID7m79aw8d0dMlxuXHD2QGxEX3Fhl/mUPA80WwEW5VgeOpUHNahhwWJefDdoBDVZcDfbHuf429NrcK0gxQsQjA==", "license": "MIT", "dependencies": { "@commitlint/top-level": "^21.0.2", - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "git-raw-commits": "^5.0.0", "tinyexec": "^1.0.0" }, @@ -208,13 +209,13 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.0.1.tgz", - "integrity": "sha512-0DhjYWL6uYrY16Efa032fYk3woGJDU4AGWiG1XXltT9AMUNYKyb5cIZU2ivbaMZ3+kKFqUjikD2cjh66Sbh/Sg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.1.0.tgz", + "integrity": "sha512-SANYkxJDfMl3TvnyALWHEaiF5nc6FFaOnh7VvfxjT4X2vD4i2gVHhmfMm1fsrBwDRX98/XyM1XDo5sAd/KXcyQ==", "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.0.1", - "@commitlint/types": "^21.0.1", + "@commitlint/config-validator": "^21.1.0", + "@commitlint/types": "^21.1.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" @@ -224,15 +225,15 @@ } }, "node_modules/@commitlint/rules": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.0.2.tgz", - "integrity": "sha512-k6tQ69Td7t2qUSIbik8D3TL1q3ZJpkEbV+yLogDzCRAdOxJm4ndhtBNREsLA1/puRfWvzS9eioF2w43WT+hHgQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.1.0.tgz", + "integrity": "sha512-fOPEYSmKn1ZJptjLmCEjJfYqz0PUYr8ng6VY2ZW26sB7KtENR90CmAXHEmScBbOIZip+d/+OwqK12DFBuHTqsQ==", "license": "MIT", "dependencies": { - "@commitlint/ensure": "^21.0.1", + "@commitlint/ensure": "^21.1.0", "@commitlint/message": "^21.0.2", "@commitlint/to-lines": "^21.0.1", - "@commitlint/types": "^21.0.1" + "@commitlint/types": "^21.1.0" }, "engines": { "node": ">=22.12.0" @@ -260,9 +261,9 @@ } }, "node_modules/@commitlint/types": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.0.1.tgz", - "integrity": "sha512-4u7w8jcoCUFWhjWnASYzZHAP34OqOtuFBN87nQmFvqda03YU0T6z+yB4w0gSAMpekiRqqGk5rt+qSlW+a2vSEg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.1.0.tgz", + "integrity": "sha512-YodnnnH1Cp+08nP8HGNJAIuB6L3/vdCTHVRTfF8Ik/wRCLOTsU9zwv3yO1cSPQRDa9CLYtE+UJ2K67r7CwMSFw==", "license": "MIT", "dependencies": { "conventional-commits-parser": "^6.3.0", @@ -326,9 +327,9 @@ } }, "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", "license": "MIT", "peer": true, "dependencies": { @@ -540,9 +541,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz", - "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==", + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", "license": "MIT", "workspaces": [ "docs", @@ -605,6 +606,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", + "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", "license": "MIT", "dependencies": { "@conventional-changelog/git-client": "^2.6.0", diff --git a/package.json b/package.json index a5a3ce51..d81a4fb5 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "author": "", "dependencies": { - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2" + "@commitlint/cli": "^21.1.0", + "@commitlint/config-conventional": "^21.1.0" }, "description": "", "devDependencies": { diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index 5df0ff72..7e3619f5 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -620,9 +620,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -640,9 +637,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -660,9 +654,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -680,9 +671,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1201,6 +1189,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", @@ -2790,9 +2794,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2809,9 +2810,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2828,9 +2826,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2847,9 +2842,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2866,9 +2858,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2885,9 +2874,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3075,9 +3061,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3095,9 +3078,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3115,9 +3095,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3135,9 +3112,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3155,9 +3129,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3175,9 +3146,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3380,9 +3348,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3399,9 +3364,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3418,9 +3380,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3437,9 +3396,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4479,9 +4435,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4502,9 +4455,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4525,9 +4475,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4548,9 +4495,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4840,6 +4784,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "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.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -5635,6 +5626,7 @@ "@babel/preset-react": "8.0.1", "@babel/preset-typescript": "8.0.1", "@biomejs/biome": "2.5.0", + "@playwright/test": "^1.61.0", "@tanstack/react-query-devtools": "5.101.0", "@types/node": "26.0.0", "@types/react": "19.2.17", diff --git a/web/ui/react-app/.gitignore b/web/ui/react-app/.gitignore new file mode 100644 index 00000000..e2caba9e --- /dev/null +++ b/web/ui/react-app/.gitignore @@ -0,0 +1,12 @@ +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/results.json +/playwright/.cache/ +/playwright/.auth/ +/tests/playwright-test-config.yml +/tests/playwright-tests.db +/tests/playwright-argus.pid +/tests/playwright-test.log diff --git a/web/ui/react-app/package.json b/web/ui/react-app/package.json index 4b65598b..92462ef6 100644 --- a/web/ui/react-app/package.json +++ b/web/ui/react-app/package.json @@ -50,6 +50,7 @@ "@babel/preset-react": "8.0.1", "@babel/preset-typescript": "8.0.1", "@biomejs/biome": "2.5.0", + "@playwright/test": "^1.61.0", "@tanstack/react-query-devtools": "5.101.0", "@types/node": "26.0.0", "@types/react": "19.2.17", @@ -71,7 +72,8 @@ "build": "tsc -b && vite build", "lint": "biome lint .", "preview": "vite preview", - "start": "vite --host" + "start": "vite --host", + "typecheck:e2e": "tsc -p tsconfig.playwright.json" }, "type": "module", "version": "0.29.4" diff --git a/web/ui/react-app/playwright.config.ts b/web/ui/react-app/playwright.config.ts new file mode 100644 index 00000000..c0d9b156 --- /dev/null +++ b/web/ui/react-app/playwright.config.ts @@ -0,0 +1,99 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * Specs that create/delete services must run one-at-a-time per browser. + * They suffix their service IDs per project to avoid cross-browser collisions, + * but they all drive a single shared backend and a dashboard that re-renders on + * every service add/remove. Running them concurrently makes the stateful flows + * (modal create/save, the ordering drag) race that churn and time out, so each + * `-mutating` project below caps itself to one worker. + */ +const SERIAL_SPECS = [ + 'create-service.spec.ts', + 'webhook.spec.ts', + 'service-secret-inheritance.spec.ts', + 'service-ordering.spec.ts', +]; + +const BROWSERS = { + chromium: devices['Desktop Chrome'], + firefox: devices['Desktop Firefox'], + webkit: devices['Desktop Safari'], +}; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Hard ceiling on the whole run so a stuck backend (an unreachable endpoint + * or an exhausted API rate limit) can't hang the suite for very long. */ + globalTimeout: process.env.CI ? 30 * 60_000 : 15 * 60_000, + + /* One project per browser, split into a parallel-safe project and a + * single-worker `-mutating` project. */ + projects: [ + ...Object.entries(BROWSERS).flatMap(([name, use]) => [ + { + name, + testIgnore: SERIAL_SPECS, + use, + }, + { + name: `${name}-mutating`, + testMatch: SERIAL_SPECS, + use, + /* Mutating specs run one-at-a-time per browser. */ + workers: 1, + }, + ]), + + /* Mobile viewports run the read-only/validation specs only, to avoid + * cross-project contention. Skip navigation since navbar differs on mobile */ + { + name: 'mobile--google-chrome', + testIgnore: [...SERIAL_SPECS, 'navigation.spec.ts'], + use: devices['Pixel 8'], + }, + { + name: 'mobile--safari', + testIgnore: [...SERIAL_SPECS, 'navigation.spec.ts'], + use: devices['iPhone 16'], + }, + ], + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [['github'], ['html'], ['json', { outputFile: 'results.json' }]] + : [['html']], + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + testDir: './tests', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Bound individual interactions/navigations so a single stuck action + * fails fast rather than riding out the (tripled, via `test.slow()`) + * test timeout. */ + actionTimeout: 15_000, + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: process.env.BASE_URL ?? 'http://localhost:8080', + navigationTimeout: 30_000, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + /* Total worker pool. The `-mutating` projects cap themselves to 1 worker + * each regardless of this value; everything else uses the full pool. */ + workers: process.env.CI ? '50%' : undefined, +}); diff --git a/web/ui/react-app/tests/create-service.spec.ts b/web/ui/react-app/tests/create-service.spec.ts new file mode 100644 index 00000000..e2e73f32 --- /dev/null +++ b/web/ui/react-app/tests/create-service.spec.ts @@ -0,0 +1,289 @@ +import { expect, type Page, test } from '@playwright/test'; +import { + type CreateServiceOptions, + cleanupServices, + createService, + deleteService, + LOOKUP_LATEST_VERSION_JSON, + screenshot, + withProject, +} from './fixtures/service'; +import { + bareEndpoint, + LOOKUP_BASIC_AUTH, + LOOKUP_WITH_HEADER_AUTH, +} from './fixtures/test-endpoints'; + +// Creating a service makes a real server-side network call that can exceed the +// default timeout under load - so triple it. +test.beforeEach(() => { + test.slow(); +}); + +/** + * Runs a create -> verify -> refresh -> delete cycle, screenshotting each stage. + * + * @param page - The dashboard page. + * @param id - The (project-suffixed) ID used for backend operations. + * @param baseID - The raw name used for screenshot paths. + * @param projectName - The browser project name. + * @param options - Options passed through to `createService`. + */ +const runCreateServiceTest = async ( + page: Page, + id: string, + baseID: string, + projectName: string, + options?: CreateServiceOptions, +) => { + await page.goto('/'); + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + await screenshot( + page, + `service-create/${baseID}/01-before-create`, + projectName, + ); + + await createService(page, id, options); + // `exact: true` - some IDs here are prefixes of others, and name matching is + // substring-based by default (would match multiple headings). + await expect( + page.getByRole('heading', { exact: true, name: id }), + ).toBeVisible(); + await screenshot( + page, + `service-create/${baseID}/02-after-create`, + projectName, + ); + + // `reload` (unlike `goto`) keeps edit mode active - no need to re-toggle. + await page.reload(); + await expect( + page.getByRole('heading', { exact: true, name: id }), + ).toBeVisible(); + await screenshot( + page, + `service-create/${baseID}/03-after-refresh`, + projectName, + ); + + await deleteService(page, id); + await screenshot( + page, + `service-create/${baseID}/04-after-delete`, + projectName, + ); +}; + +test.describe('Service creation', () => { + // Safety net for a test that fails before its own `deleteService` step. + let createdID: string | undefined; + test.afterEach(async ({ page }) => { + if (createdID) await cleanupServices(page, [createdID]); + createdID = undefined; + }); + + test('latest-version=github', async ({ page }, testInfo) => { + const baseID = 'LATEST_VERSION=GITHUB'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + latestVersion: { + type: 'github', + url: 'release-argus/Argus', + }, + }); + }); + + test('latest-version=url', async ({ page }, testInfo) => { + const baseID = 'LATEST_VERSION=URL'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + latestVersion: { + type: 'url', + url: bareEndpoint('1.2.3'), + }, + }); + }); + + test('deployed-version=manual', async ({ page }, testInfo) => { + const baseID = 'DEPLOYED_VERSION=MANUAL'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + deployedVersion: { + type: 'manual', + version: '1.2.3', + }, + }); + }); + + test('deployed-version=url (JSON)', async ({ page }, testInfo) => { + const baseID = 'DEPLOYED_VERSION=URL'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + deployedVersion: { + json: 'version', + type: 'url', + url: bareEndpoint('{"version":"1.2.3"}'), + }, + }); + }); + + test('deployed-version=url (basic auth)', async ({ page }, testInfo) => { + const baseID = 'DEPLOYED_VERSION=URL BASIC-AUTH'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + deployedVersion: { + basicAuth: { + password: LOOKUP_BASIC_AUTH.password, + username: LOOKUP_BASIC_AUTH.username, + }, + type: 'url', + url: LOOKUP_BASIC_AUTH.urlValid, + }, + // /basic-auth returns a sentence, not a bare semver - disable semantic versioning. + semanticVersioning: false, + }); + }); + + test('deployed-version=url (header auth)', async ({ page }, testInfo) => { + const baseID = 'DEPLOYED_VERSION=URL HEADER-AUTH'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + deployedVersion: { + headers: [ + { + key: LOOKUP_WITH_HEADER_AUTH.headerKey, + value: LOOKUP_WITH_HEADER_AUTH.headerValuePass, + }, + ], + type: 'url', + url: LOOKUP_WITH_HEADER_AUTH.urlValid, + }, + }); + }); +}); + +test.describe('Service update status', () => { + // Safety net for a test that fails before its own `deleteService` step. + let createdID: string | undefined; + test.afterEach(async ({ page }) => { + if (createdID) await cleanupServices(page, [createdID]); + createdID = undefined; + }); + + test('latest_version === deployed_version (up to date)', async ({ + page, + }, testInfo) => { + const baseID = 'UPDATE_STATUS=UP_TO_DATE'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + + await page.goto('/'); + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + + // GIVEN: a service whose `latest_version` and `deployed_version` lookups + // both resolve to the same version (1.2.3). + await createService(page, id, { + deployedVersion: { type: 'manual', version: '1.2.3' }, + latestVersion: LOOKUP_LATEST_VERSION_JSON, + }); + await expect(page.getByRole('heading', { name: id })).toBeVisible(); + + const serviceCard = page.locator(`[data-service-id="${id}"]`); + + // THEN: the "Deployed" version is shown as 1.2.3. + await expect(serviceCard.getByText('1.2.3')).toBeVisible(); + + // AND: there is no separate "latest version" indicator for a different + // version - only the single (deployed) version value is rendered. + await expect(serviceCard.getByText(/^\d+\.\d+\.\d+$/)).toHaveCount(1); + + // AND: the card is not flagged as having an update available. + await expect(serviceCard).toHaveAttribute('data-update-available', 'false'); + + // AND: there is no "Skip" button (no update to skip). + await expect( + serviceCard.getByRole('button', { name: /reject release/i }), + ).not.toBeVisible(); + + await screenshot( + page, + `service-update-status/${baseID}/01-up-to-date`, + testInfo.project.name, + ); + }); + + test('latest_version !== deployed_version (update available)', async ({ + page, + }, testInfo) => { + const baseID = 'UPDATE_STATUS=AVAILABLE'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + + await page.goto('/'); + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + + // GIVEN: a service whose `latest_version` (1.2.3) and `deployed_version` + // (0.0.1) lookups resolve to different versions. + await createService(page, id, { + deployedVersion: { type: 'manual', version: '0.0.1' }, + latestVersion: LOOKUP_LATEST_VERSION_JSON, + }); + await expect(page.getByRole('heading', { name: id })).toBeVisible(); + + const serviceCard = page.locator(`[data-service-id="${id}"]`); + const skipButton = serviceCard.getByRole('button', { + name: /reject release/i, + }); + + // THEN: both the "Latest" (1.2.3) and "Deployed" (0.0.1) versions are + // displayed. + await expect(serviceCard.getByText('1.2.3')).toBeVisible(); + await expect(serviceCard.getByText('0.0.1')).toBeVisible(); + + // AND: the card is flagged as having an update available. + await expect(serviceCard).toHaveAttribute('data-update-available', 'true'); + + // AND: a "Skip" button is visible. + await expect(skipButton).toBeVisible(); + await screenshot( + page, + `service-update-status/${baseID}/01-update-available`, + testInfo.project.name, + ); + + // WHEN: the user clicks "Skip" and confirms in the resulting modal. + await skipButton.click(); + const dialog = page.getByRole('dialog', { name: /skip this release\?/i }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('Stay on: 0.0.1')).toBeVisible(); + await expect(dialog.getByText('Skip: 1.2.3')).toBeVisible(); + await screenshot( + page, + `service-update-status/${baseID}/02-skip-modal`, + testInfo.project.name, + ); + + await dialog.locator('#modal-action').click(); + + // THEN: the modal closes. + await expect(dialog).not.toBeVisible(); + + // AND: the card is no longer flagged as having an update available. + await expect(serviceCard).toHaveAttribute('data-update-available', 'false'); + + // AND: the "Skip" button is no longer shown. + await expect(skipButton).not.toBeVisible(); + await screenshot( + page, + `service-update-status/${baseID}/03-after-skip`, + testInfo.project.name, + ); + }); +}); diff --git a/web/ui/react-app/tests/dashboard.spec.ts b/web/ui/react-app/tests/dashboard.spec.ts new file mode 100644 index 00000000..6ce9c194 --- /dev/null +++ b/web/ui/react-app/tests/dashboard.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Dashboard', () => { + test('has the correct title', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/Argus/); + }); + + test('dashboard is visible', async ({ page }) => { + await page.goto('/'); + await expect( + page.getByRole('heading', { exact: true, name: 'release-argus/Argus' }), + ).toBeVisible(); + }); +}); diff --git a/web/ui/react-app/tests/fixtures/service.ts b/web/ui/react-app/tests/fixtures/service.ts new file mode 100644 index 00000000..36ba8aa8 --- /dev/null +++ b/web/ui/react-app/tests/fixtures/service.ts @@ -0,0 +1,527 @@ +import { expect, type Locator, type Page, test } from '@playwright/test'; +import { bareEndpoint } from './test-endpoints'; + +export type KeyVal = { key: string; value: string }; + +/** + * Appends the browser project's name to a base ID so browser tests never + * collide on the same literal name against the shared backend. + * + * @param id - The base service ID. + * @param projectName - The browser project name (`testInfo.project.name`). + * @returns The project-suffixed ID. + */ +export const withProject = (id: string, projectName: string) => + `${id}-${projectName}`; + +export type LatestVersionOptions = { + /* `latest_version.type` - defaults to 'github'. */ + type?: 'github' | 'url'; + /* `latest_version.url` (the GitHub "Repository" or generic "URL" field). */ + url: string; + /* `latest_version.allow_invalid_certs` - only applicable to type 'url'. */ + allowInvalidCerts?: boolean; + /* `latest_version.headers` - only applicable to type 'url'. */ + headers?: KeyVal[]; + /* `latest_version.url_commands` (only the first command, type 'regex') - + * only applicable to type 'url'. Used to extract a version from a + * response body that isn't a bare semver string. */ + urlCommandRegex?: { regex: string; index?: string }; +}; + +export type DeployedVersionOptions = + | { + /* `deployed_version.type` = 'manual'. */ + type: 'manual'; + /* `deployed_version.version`. */ + version: string; + } + | { + /* `deployed_version.type` = 'url'. */ + type: 'url'; + /* `deployed_version.url`. */ + url: string; + /* `deployed_version.method` - defaults to 'GET'. */ + method?: 'GET' | 'POST'; + /* `deployed_version.allow_invalid_certs`. */ + allowInvalidCerts?: boolean; + /* `deployed_version.basic_auth`. */ + basicAuth?: { username: string; password: string }; + /* `deployed_version.headers`. */ + headers?: KeyVal[]; + /* `deployed_version.body` - only sent when `method` is 'POST'. */ + body?: string; + /* `deployed_version.target_header`. */ + targetHeader?: string; + /* `deployed_version.json`. */ + json?: string; + /* `deployed_version.regex`. */ + regex?: string; + }; + +export type WebHookOptions = { + /* `webhook.X.name`. */ + name: string; + /* `webhook.X.url`. */ + url: string; + /* `webhook.X.secret`. */ + secret: string; + /* `webhook.X.max_tries`. */ + maxTries?: string; +}; + +/* A `gotify` notifier. */ +export type NotifyOptions = { + /* `notify.X.type` - only 'gotify' is supported here. */ + type: 'gotify'; + /* `notify.X.name`. */ + name: string; + /* `notify.X.url_fields.host`. */ + host: string; + /* `notify.X.url_fields.path`. */ + path?: string; + /* `notify.X.url_fields.token` (the masked secret). */ + token: string; + /* `notify.X.params.title`. */ + title?: string; +}; + +export type CreateServiceOptions = { + latestVersion?: LatestVersionOptions; + deployedVersion?: DeployedVersionOptions; + /* `options.semantic_versioning` - set to `false` for lookups whose raw + * response isn't a parseable MAJOR.MINOR.PATCH version. */ + semanticVersioning?: boolean; + notifiers?: NotifyOptions[]; + webhooks?: WebHookOptions[]; +}; + +// A `url` latest-version lookup against the JSON fixture, extracting its +// version via a regex url_command. +export const LOOKUP_LATEST_VERSION_JSON: LatestVersionOptions = { + allowInvalidCerts: false, + type: 'url', + url: bareEndpoint('{"version":"1.2.3"}'), + urlCommandRegex: { + index: '0', + regex: '"version":\\s*"([\\d.]+)"', + }, +}; + +/** + * Screenshots `page` to `test-results/screenshots///.png`. + * + * @param page - The page to capture. + * @param name - Path-like `/` (no browser segment or extension). + * @param projectName - Browser project; becomes the `` segment (its + * `-mutating` suffix stripped) so projects don't collide. + * @param centerOn - If set, scroll this field to centre and capture only the + * viewport rather than the full page. + * @returns The screenshot buffer. + */ +export const screenshot = async ( + page: Page, + name: string, + projectName: string, + centerOn?: Locator, +) => { + const lastSlash = name.lastIndexOf('/'); + const dir = name.slice(0, lastSlash + 1); + const file = name.slice(lastSlash + 1); + const browser = projectName.replace(/-mutating$/, ''); + if (centerOn) { + await centerOn.evaluate((el) => + el.scrollIntoView({ behavior: 'instant', block: 'center' }), + ); + } + return page.screenshot({ + // Fast-forward in-flight CSS transitions so a field whose validation + // just cleared isn't captured mid-fade. + animations: 'disabled', + fullPage: !centerOn, + path: `test-results/screenshots/${dir}${browser}/${file}.png`, + }); +}; + +// A filesystem-safe slug of a test's title - used as a per-test folder name. +const titleSlug = (title: string) => + title.replace(/[^\w=.]+/g, '-').replace(/^-+|-+$/g, ''); + +/** + * Binds `screenshot` to a fixed `base` dir and project. Each call's screenshot + * lands under `base//`, so every test in a describe gets its own + * folder (the test title comes from `test.info()`). + * + * @param page - The page to capture. + * @param projectName - The browser project name. + * @param base - The shared screenshot directory prefix. + * @returns A bound screenshot taker. + */ +export const screenshotsUnder = + (page: Page, projectName: string, base: string) => + (file: string, centerOn?: Locator) => + screenshot( + page, + `${base}/${titleSlug(test.info().title)}/${file}`, + projectName, + centerOn, + ); + +/** + * Fills the headers map for a lookup `section`, adding a row per entry. + * + * @param section - The accordion section containing the headers map. + * @param prefix - The lookup's field-name prefix (`latest_version` or + * `deployed_version`); the rows live under `.headers`. + * @param entries - The key/value pairs to add. + */ +const fillHeaders = async ( + section: Locator, + prefix: 'latest_version' | 'deployed_version', + entries: KeyVal[], +) => { + for (let index = 0; index < entries.length; index++) { + await section.getByRole('button', { name: /add new headers/i }).click(); + await section + .locator(`input[name="${prefix}.headers.${index}.key"]`) + .fill(entries[index].key); + await section + .locator(`input[name="${prefix}.headers.${index}.value"]`) + .fill(entries[index].value); + } +}; + +/** + * Sets a Yes/No/Default toggle for `fieldName`. + * + * @param section - The accordion section containing the toggle. + * @param fieldName - The form field name. + * @param value - `true` selects Yes, `false` No; omit to select Default. + */ +export const setBooleanWithDefault = async ( + section: Locator, + fieldName: string, + value?: boolean, +) => { + const name = + value === undefined ? /^Default:?$/i : value ? /^Yes$/i : /^No$/i; + await section + .locator(`[aria-labelledby="${fieldName}-label"]`) + .getByRole('radio', { name }) + .click(); +}; + +/** + * Fills in the `latest_version` accordion section. + * + * @param dialog - The modal dialog. + * @param options - The latest-version lookup options. + */ +const fillLatestVersion = async ( + dialog: Locator, + options: LatestVersionOptions, +) => { + const type = options.type ?? 'github'; + + await dialog.getByRole('button', { name: /^Latest Version:?$/i }).click(); + const section = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'Latest Version' }) + .first(); + + await section.locator('#latest_version\\.type').click(); + await dialog.getByRole('option', { name: type }).click(); + + if (type === 'github') { + await section + .getByRole('textbox', { name: /repository/i }) + .fill(options.url); + return; + } + + // type === 'url' + await section + .getByRole('textbox', { name: /^Value field for URL$/i }) + .fill(options.url); + + if (options.allowInvalidCerts !== undefined) { + await setBooleanWithDefault( + section, + 'latest_version.allow_invalid_certs', + options.allowInvalidCerts, + ); + } + + if (options.headers?.length) { + await fillHeaders(section, 'latest_version', options.headers); + } + + if (options.urlCommandRegex) { + // Add a regex url_command to extract the version from the response body. + await section.getByRole('button', { name: /add new url command/i }).click(); + await section + .locator('input[name="latest_version.url_commands.0.regex"]') + .fill(options.urlCommandRegex.regex); + if (options.urlCommandRegex.index !== undefined) { + await section + .locator('input[name="latest_version.url_commands.0.index"]') + .fill(options.urlCommandRegex.index); + } + } +}; + +/** + * Fills in the `deployed_version` accordion section. + * + * @param dialog - The modal dialog. + * @param options - The deployed-version lookup options. + */ +const fillDeployedVersion = async ( + dialog: Locator, + options: DeployedVersionOptions, +) => { + await dialog.getByRole('button', { name: /^Deployed Version:?$/i }).click(); + const section = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'Deployed Version' }) + .first(); + + await section.locator('#deployed_version\\.type').click(); + await dialog.getByRole('option', { name: options.type }).click(); + + if (options.type === 'manual') { + await section + .locator('input[name="deployed_version.version"]') + .fill(options.version); + return; + } + + // type === 'url' + await section + .getByRole('textbox', { name: /^Value field for URL$/i }) + .fill(options.url); + + if (options.method) { + await section.locator('#deployed_version\\.method').click(); + await dialog + .getByRole('option', { exact: true, name: options.method }) + .click(); + } + + if (options.allowInvalidCerts !== undefined) { + await setBooleanWithDefault( + section, + 'deployed_version.allow_invalid_certs', + options.allowInvalidCerts, + ); + } + + if (options.basicAuth) { + await section + .locator('input[name="deployed_version.basic_auth.username"]') + .fill(options.basicAuth.username); + await section + .locator('input[name="deployed_version.basic_auth.password"]') + .fill(options.basicAuth.password); + } + + if (options.headers?.length) { + await fillHeaders(section, 'deployed_version', options.headers); + } + + if (options.method === 'POST' && options.body !== undefined) { + await section.getByRole('textbox', { name: /^Body$/i }).fill(options.body); + } + + if (options.targetHeader !== undefined) { + await section + .getByRole('textbox', { name: /target header/i }) + .fill(options.targetHeader); + } + + if (options.json !== undefined) { + await section.getByRole('textbox', { name: /json/i }).fill(options.json); + } + + if (options.regex !== undefined) { + await section + .getByRole('textbox', { name: 'Value field for RegEx' }) + .fill(options.regex); + } +}; + +/** + * Fills in the `webhook` accordion section, adding one item per entry. + * + * @param dialog - The modal dialog. + * @param webhooks - The webhooks to add. + */ +const fillWebHooks = async (dialog: Locator, webhooks: WebHookOptions[]) => { + await dialog.getByRole('button', { name: /^WebHook:?$/i }).click(); + const section = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'WebHook' }) + .first(); + + for (let index = 0; index < webhooks.length; index++) { + await section.getByRole('button', { name: /add webhook/i }).click(); + + const item = section.locator('[data-slot="accordion-item"]').nth(index); + await item.locator('[data-slot="accordion-trigger"]').click(); + + await item + .getByRole('textbox', { name: /^Value field for Name$/i }) + .fill(webhooks[index].name); + await item + .getByRole('textbox', { name: /^Value field for Target URL$/i }) + .fill(webhooks[index].url); + await item + .getByRole('textbox', { name: /^Value field for Secret$/i }) + .fill(webhooks[index].secret); + + const { maxTries } = webhooks[index]; + if (maxTries !== undefined) { + await item + .getByRole('textbox', { name: /^Value field for Max tries$/i }) + .fill(maxTries); + } + } +}; + +/** + * Fills in the `notify` accordion section, adding one item per entry. + * + * @param dialog - The modal dialog. + * @param notifiers - The notifiers to add. + */ +const fillNotify = async (dialog: Locator, notifiers: NotifyOptions[]) => { + await dialog.getByRole('button', { name: /^Notify:?$/i }).click(); + const section = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'Notify' }) + .first(); + + for (let index = 0; index < notifiers.length; index++) { + const notify = notifiers[index]; + await section.getByRole('button', { name: /add notify/i }).click(); + + const item = section.locator('[data-slot="accordion-item"]').nth(index); + await item.locator('[data-slot="accordion-trigger"]').click(); + + await section.locator(`#notify\\.${index}\\.type`).click(); + await dialog.getByRole('option', { exact: true, name: 'Gotify' }).click(); + + await item + .getByRole('textbox', { name: /^Value field for Name$/i }) + .fill(notify.name); + await item + .getByRole('textbox', { name: /^Value field for Host$/i }) + .fill(notify.host); + if (notify.path !== undefined) { + await item + .getByRole('textbox', { name: /^Value field for Path$/i }) + .fill(notify.path); + } + await item + .getByRole('textbox', { name: /^Value field for Token$/i }) + .fill(notify.token); + if (notify.title !== undefined) { + await item + .getByRole('textbox', { name: /^Value field for Title$/i }) + .fill(notify.title); + } + } +}; + +/** + * Creates a service. Defaults to a `url` 'latest version' lookup against the + * test server's `/bare/1.2.3` endpoint; pass `latestVersion`/`deployedVersion` + * to exercise other lookup types. + * + * @param page - The dashboard page (edit mode must already be on). + * @param id - The service ID. + * @param options - Lookup/notify/webhook options for the service. + */ +export const createService = async ( + page: Page, + id: string, + options?: CreateServiceOptions, +) => { + await page.getByRole('button', { name: /create a service/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.locator('input[name="id"]').fill(id); + + await fillLatestVersion( + dialog, + options?.latestVersion ?? LOOKUP_LATEST_VERSION_JSON, + ); + + if (options?.deployedVersion) { + await fillDeployedVersion(dialog, options.deployedVersion); + } + + if (options?.semanticVersioning !== undefined) { + await dialog.getByRole('button', { name: /^Options:?$/i }).click(); + const optionsSection = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'Options' }) + .first(); + await setBooleanWithDefault( + optionsSection, + 'options.semantic_versioning', + options.semanticVersioning, + ); + } + + if (options?.notifiers?.length) { + await fillNotify(dialog, options.notifiers); + } + + if (options?.webhooks?.length) { + await fillWebHooks(dialog, options.webhooks); + } + + await dialog.locator('#modal-action').click(); + // The server verifies the lookup via a real network call before + // responding, so the modal can take longer than the default 5s to close. + await expect(dialog).not.toBeVisible({ timeout: 30_000 }); +}; + +/** + * Deletes the service with the given ID via its edit modal. + * + * @param page - The dashboard page (edit mode must already be on). + * @param serviceID - The ID of the service to delete. + */ +export const deleteService = async (page: Page, serviceID: string) => { + const serviceCard = page.locator(`[data-service-id="${serviceID}"]`); + await serviceCard.getByRole('button', { name: /edit/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: /^Delete$/i }).click(); + + // Confirm in the resulting dialog. + await page + .getByRole('button', { name: /^Delete$/i }) + .last() + .click(); + + // The card disappears once the delete is broadcast over the WebSocket - + // allow extra time under parallel load. + await expect( + page.locator(`[data-service-id="${serviceID}"]`), + ).not.toBeVisible({ timeout: 30_000 }); +}; + +/** + * Deletes each of `serviceIDs` that is currently present. + * + * @param page - The dashboard page. + * @param serviceIDs - The IDs to remove if present. + */ +export const cleanupServices = async (page: Page, serviceIDs: string[]) => { + await page.goto('/'); + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + for (const id of serviceIDs) { + if (await page.locator(`[data-service-id="${id}"]`).isVisible()) { + await deleteService(page, id); + } + } +}; diff --git a/web/ui/react-app/tests/fixtures/test-endpoints.ts b/web/ui/react-app/tests/fixtures/test-endpoints.ts new file mode 100644 index 00000000..a69a9b50 --- /dev/null +++ b/web/ui/react-app/tests/fixtures/test-endpoints.ts @@ -0,0 +1,60 @@ +// Test endpoints mirrored from `test/secrets.go` - keep in sync with it. + +// Domain with a valid TLS certificate. +export const VALID_CERT_NO_PROTOCOL = 'valid.release-argus.io'; +export const VALID_CERT_HTTPS = `https://${VALID_CERT_NO_PROTOCOL}`; + +// Domain with an invalid/self-signed TLS certificate. +export const INVALID_CERT_HTTPS = 'https://invalid.release-argus.io'; + +/** + * Builds a `/bare/` URL on the test server, which echoes + * `body` back as the response. Lets a test pin the exact lookup response. + * + * @param body - The exact response body the endpoint should return. + * @param invalidCert - Target the invalid-cert host instead of the valid one. + */ +export const bareEndpoint = (body: string, invalidCert = false) => + `${invalidCert ? INVALID_CERT_HTTPS : VALID_CERT_HTTPS}/bare/${encodeURIComponent(body)}`; + +// Version carried in a response header. +export const LOOKUP_RESPONSE_HEADER = { + headerKeyFail: 'X-Version-Foo', + headerKeyPass: 'X-Version-Here', + headerKeyPassMixedCase: 'x-VeRSioN-HERe', + urlInvalid: `${INVALID_CERT_HTTPS}/header`, + urlValid: `${VALID_CERT_HTTPS}/header`, +}; + +// Requires a custom request header to succeed. +export const LOOKUP_WITH_HEADER_AUTH = { + headerKey: 'X-Test', + headerValueFail: 'secret-', + headerValuePass: 'secret', + urlInvalid: `${INVALID_CERT_HTTPS}/hooks/single-header`, + urlValid: `${VALID_CERT_HTTPS}/hooks/single-header`, +}; + +// Requires HTTP basic auth to succeed. +export const LOOKUP_BASIC_AUTH = { + password: '123', + urlInvalid: `${INVALID_CERT_HTTPS}/basic-auth`, + urlValid: `${VALID_CERT_HTTPS}/basic-auth`, + username: 'test', +}; + +// WebHook receiver requiring a matching secret. +export const WEBHOOK_GITHUB = { + secretFail: 'argus-', + secretPass: 'argus', + urlInvalid: `${INVALID_CERT_HTTPS}/hooks/github-style`, + urlValid: `${VALID_CERT_HTTPS}/hooks/github-style`, +}; + +// A real Gotify endpoint that accepts `tokenPass`, rejecting anything else. +export const NOTIFY_GOTIFY = { + host: VALID_CERT_NO_PROTOCOL, + path: '/gotify', + // trunk-ignore(gitleaks/generic-api-key) + tokenPass: 'AGE-LlHU89Q56uQ', +}; diff --git a/web/ui/react-app/tests/fixtures/validation.ts b/web/ui/react-app/tests/fixtures/validation.ts new file mode 100644 index 00000000..de76b915 --- /dev/null +++ b/web/ui/react-app/tests/fixtures/validation.ts @@ -0,0 +1,341 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +// Validation messages for the modal forms. +export const REQUIRED = 'Required.'; +export const NUMBER_REQUIRED = 'Number required.'; +export const MUST_BE_UNIQUE = 'Must be unique.'; + +/** + * The field container for `input` - scopes assertions to one field's error so + * they don't match other fields' errors. + * + * @param input - The field's input element. + * @returns The enclosing `[data-slot="field"]` container. + */ +export const fieldOf = (input: Locator) => + input.locator('xpath=ancestor::*[@data-slot="field"][1]'); + +/** + * Opens the "Create a service" modal, entering edit mode first. + * + * @param page - The dashboard page. + * @returns The open modal dialog. + */ +export const openCreateServiceModal = async (page: Page) => { + await page.goto('/'); + // Wait for /api/v1/service/order response. + await expect(page.locator('[data-service-id]').first()).toBeVisible(); + + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + await page.getByRole('button', { name: /create a service/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + return dialog; +}; + +/** + * Expands the top-level accordion section with the given name. + * + * @param dialog - The modal dialog. + * @param name - The section's heading text (e.g. 'Latest Version'). + * @returns The section's accordion-item container. + */ +export const openSection = async (dialog: Locator, name: string) => { + await dialog + .getByRole('button', { name: new RegExp(`^${name}:?$`, 'i') }) + .click(); + return dialog + .locator('[data-slot="accordion-item"]', { hasText: name }) + .first(); +}; + +/** + * Waits for an accordion item's resize animations to finish before a + * screenshot, so error text isn't captured mid-resize. + * + * @param section - The accordion item. + */ +export const waitForAccordionAnimations = (section: Locator) => + section.evaluate((el) => + Promise.all( + el + .getAnimations({ subtree: true }) + .map((a) => a.finished.catch(() => undefined)), + ), + ); + +/** + * Activates a button via keyboard (focus + Enter). Used for icon-only + * add/remove buttons whose empty-array row can intercept the pointer and make + * `.click()` time out - keyboard activation skips hit-testing. + * + * @param button - The button to activate. + */ +export const clickViaKeyboard = async (button: Locator) => { + await button.focus(); + await button.press('Enter'); +}; + +// A bound screenshot taker (from `screenshotsUnder`): a leaf file name and an +// optional field to centre on. +type Shot = (file: string, centerOn?: Locator) => Promise; + +/** + * Adds a notifier to the (already-open) Notify `section` and expands it. + * + * @param section - The Notify accordion section. + * @param dialog - The modal dialog (for the type-select options). + * @param type - Visible option label (e.g. 'Gotify') to switch from the default + * notifier; omit to leave it as-is. + */ +export const addNotify = async ( + section: Locator, + dialog: Locator, + type?: string, +) => { + await section.getByRole('button', { name: /add notify/i }).click(); + const header = section.locator('[data-slot="accordion-trigger"]', { + hasText: /^0:/, + }); + await expect(header).toBeVisible(); + await header.click(); + if (!type) return; + await section.locator('#notify\\.0\\.type').click(); + await dialog.getByRole('option', { exact: true, name: type }).click(); +}; + +/** + * The "Value field for