diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eaf1859..dda24daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,5 +151,7 @@ jobs: if: always() with: name: playwright-report - path: e2e/playwright-report/ + path: | + e2e/playwright-report/ + e2e/test-results/ retention-days: 14 diff --git a/.github/workflows/templates.yml b/.github/workflows/templates.yml index 93ac2bc8..2bc7b956 100644 --- a/.github/workflows/templates.yml +++ b/.github/workflows/templates.yml @@ -1,26 +1,33 @@ name: Templates -# Validate every app template by deploying it end-to-end. Triggered only -# when template definitions change, so it runs once per template-edit PR -# instead of on every push. Also exposed as a manual workflow_dispatch. +# Validate every app template by deploying it end-to-end, and check that +# every template image still resolves. Triggered when template definitions +# or the code that parses/deploys them change, weekly (upstream tags get +# yanked; the GHCR mirror used by per-push E2E hides that), and manually. on: pull_request: paths: - 'ui/src/lib/appTemplates.js' - 'ui/src/lib/serviceTemplates.js' - 'e2e/tests/templates-deploy-all.spec.js' - - 'e2e/tests/00-template-images.spec.js' + - 'e2e/scripts/check-template-images.mjs' - 'e2e/helpers/template-probes.js' - - 'internal/compose/inject.go' + - 'internal/compose/**' + - 'internal/deployer/**' + - 'go.mod' push: branches: [main] paths: - 'ui/src/lib/appTemplates.js' - 'ui/src/lib/serviceTemplates.js' - 'e2e/tests/templates-deploy-all.spec.js' - - 'e2e/tests/00-template-images.spec.js' + - 'e2e/scripts/check-template-images.mjs' - 'e2e/helpers/template-probes.js' - - 'internal/compose/inject.go' + - 'internal/compose/**' + - 'internal/deployer/**' + - 'go.mod' + schedule: + - cron: '17 4 * * 1' workflow_dispatch: permissions: @@ -31,6 +38,19 @@ concurrency: cancel-in-progress: true jobs: + template-images: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Every template image resolves + run: node e2e/scripts/check-template-images.mjs + deploy-all: # Shard the ~20-template matrix across parallel runners. Each shard # builds its own binary, runs its own server, and pre-pulls only the diff --git a/docs/contributing/e2e-tests.md b/docs/contributing/e2e-tests.md index 3e71551d..ffe56884 100644 --- a/docs/contributing/e2e-tests.md +++ b/docs/contributing/e2e-tests.md @@ -95,9 +95,13 @@ GHCR packages are **private by default**. Both CI workflows (`ci.yml`, `template ## Template validation -Two layers guard the built-in app and service templates: +Layers guarding the built-in app and service templates: -- **`00-template-images.spec.js`** (runs in `e2e` and `e2e-lite`): imports `appTemplates.js` and `serviceTemplates.js`, collects every `image:` reference, and runs `docker manifest inspect` on each in parallel. Fails fast when a tag is typo'd, yanked, or private. Takes ~10-30s. -- **`templates-deploy-all.spec.js`** (only runs under `make e2e-templates` / `E2E_TEMPLATES=1`): deploys every app template through the UI wizard, asserts the "Deployed" pill, then deletes the app via API. Expensive (pulls ~20 multi-service stacks), so it is excluded from both `e2e` and `e2e-lite`. The `.github/workflows/templates.yml` workflow triggers it automatically when `appTemplates.js`, `serviceTemplates.js`, or either template spec changes, so it runs once per template-edit PR. +- **Vitests** (`ui/src/lib/__tests__/appTemplates.test.js`, `ui/src/components/__tests__/VisualEditor.test.js`): template data, rendering, and adding every service template in the visual editor. +- **`30-app-templates-ui.spec.js`** (per-push suite): every app template passes `validate-compose` and `docker compose config`; multi-endpoint templates work in Quick test mode. +- **`e2e/scripts/check-template-images.mjs`**: runs `docker manifest inspect` on every template image (retries transient registry errors; a rate limit only warns). Run by the Templates workflow, not the per-push suite, because it hits Docker Hub directly. +- **`templates-deploy-all.spec.js`** (only under `make e2e-templates` / `E2E_TEMPLATES=1`): deploys every app template through the UI wizard and probes it over HTTP, then deletes it. Expensive (pulls ~20 multi-service stacks), so it is excluded from `e2e` and `e2e-lite`. + +`.github/workflows/templates.yml` runs the image check and the deploy matrix when templates, `internal/compose/**`, `internal/deployer/**` or `go.mod` change, and weekly (upstream tags get yanked; the mirror used by per-push E2E hides that). When adding or editing a template, bias toward running `make e2e-templates` locally before opening the PR; the CI workflow runs it too but locally you get faster feedback. diff --git a/e2e/README.md b/e2e/README.md index e28eb785..7aab1474 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -24,39 +24,50 @@ Each test gets a fresh browser context (isolated cookies) but shares the server ## Test Files +Every test must be able to fail when the feature it names breaks (see [Test design rules](#test-design-rules)). Behavior already pinned by Go tests or vitests is not repeated here; E2E covers what only the full stack can show. + | File | What it covers | |------|---------------| -| `01-setup.spec.js` | First-time admin account creation, validation | -| `02-login.spec.js` | Login, logout, wrong password, session redirect | -| `03-deploy.spec.js` | Deploy 3 apps via wizard, YAML validation, redeploy | -| `04-dashboard.spec.js` | App cards, running status, metrics, search, nav | -| `05-app-overview.spec.js` | App detail page, services list, tab navigation | -| `06-app-actions.spec.js` | Stop, start, restart, pull & update, scale | -| `07-app-config.spec.js` | Compose editor, environment variables | -| `08-app-endpoints.spec.js` | Endpoints, TLS badges, advanced settings | -| `09-app-logs.spec.js` | Log viewer, controls, deploy events | -| `10-app-metrics.spec.js` | Chart rendering, time range switching | -| `11-app-versions.spec.js` | Deploy history, version entries, redeploy + image verification | -| `12-backups.spec.js` | Backup wizard UI + **functional roundtrip**: postgres insert→backup→drop→restore→verify, volume archive inspection, retention pruning, hooks, upload-restore, checksum | -| `13-alerts.spec.js` | Webhooks CRUD + **functional dispatch**: webhook receiver + firing/resolved transitions + disabled rule + backup alerts | -| `13b-webhook-formats.spec.js` | Payload schema per integration type: Slack, Telegram, Discord, custom default, template override | -| `14-users.spec.js` | User CRUD, roles, API keys, RBAC (viewer access, super_admin guards, invalid key) | -| `15-registries.spec.js` | Registry add/delete (UI only; see `29b` for functional pull+auth) | -| `16-docker.spec.js` | Docker info, images, networks, volumes, prune | -| `17-system.spec.js` | System info, maintenance, DB backup download + sqlite validation | -| `18-profile.spec.js` | Profile edit, password change, theme toggle | -| `20-security-validation.spec.js` | Compose security (privileged, host network/pid/ipc, caps, volumes), app name validation | -| `21-user-validation.spec.js` | Duplicate username/email, short password, role guards, lockout | -| `22-env-and-endpoints.spec.js` | Env var CRUD, endpoint management, IP access allowlist validation | -| `23-rollback-and-restore.spec.js` | Deploy versions, rollback (verify image tag), backup restore | -| `24-multi-user-isolation.spec.js` | Viewer role isolation, per-app access, API key auth | -| `25-edge-cases.spec.js` | Audit log content, alert/webhook editing, dashboard filters, system prune | -| `26-tls-ssl.spec.js` | **Isolated server** with `tls.mode=local`: self-signed cert serving, custom cert upload, endpoint TLS persistence | -| `27-backup-s3.spec.js` | **MinIO fixture**: S3 roundtrip, checksum, presigned URL, retention, bogus-creds failure | -| `28-db-strategies.spec.js` | MySQL, MongoDB, Redis, SQLite strategy detection + full roundtrip against real DB containers | -| `29a-ratelimit.spec.js` | **Isolated server** with tight limits: burst→429, window recovery | -| `29b-private-registry.spec.js` | **registry:2 fixture**: push private image, register creds via API, deploy succeeds; deploy fails when creds removed | -| `99-cleanup.spec.js` | Remove all apps, verify clean dashboard | +| `01-setup.spec.js` | First-run setup creates the admin (setup status flips, `/api/me` profile) | +| `02-login.spec.js` | Login form (the only spec that uses it), wrong password 401, guarded routes, logout revokes the session server-side | +| `03-deploy.spec.js` | Deploys e2e-nginx, e2e-multi, e2e-postgres via the wizard; invalid YAML blocked; name collision suggestion; containers, labels and proxy routing | +| `03c-community-recipes.spec.js` | Recipe catalog from a local fixture server (isolated server): browse, import into the wizard, popularity count | +| `04-dashboard.spec.js` | App cards and status badges match the API, host metrics reach the UI, search, card navigation | +| `06-app-actions.spec.js` | Stop/start/restart/pull/scale through the UI, each verified in Docker and through the proxy | +| `07-app-config.spec.js` | Edit the image in the compose editor, redeploy, container runs the new image | +| `08-app-endpoints.spec.js` | Add an endpoint in Settings, TLS badge, added/removed/multiple domains route through the proxy | +| `09-app-logs.spec.js` | A request through the proxy shows up in the live log viewer; events tab matches the API | +| `10-app-metrics.spec.js` | Range switch returns data; request metrics grow with real traffic | +| `11-app-versions.spec.js` | Version history in the UI matches the API; compose download equals the stored version | +| `12-backups.spec.js` | Backup wizard saves the config; postgres backup, drop, restore roundtrip; volume archive contents; pre/post hooks run in the app container; upload-restore with checksum; tab refreshes live | +| `13-alerts.spec.js` | Webhook create + Send Test to a local receiver; rule form keeps its app; CPU alert fires, resolves, no double-fire, disabled rule silent; backup alerts name and scope the right app | +| `14-users.spec.js` | User and API key lifecycle through the UI (checked via API); RBAC: viewer and manage limits with exact status codes; deleting a user revokes their key | +| `15-registries.spec.js` | Add/delete a registry; password never returned (see `29b` for real pulls) | +| `16-docker.spec.js` | Docker page shows the daemon's real version, images, networks, volumes | +| `17-system.spec.js` | System info matches the API, VACUUM, DB download is a real SQLite file, process logs, compact backup strips metrics | +| `18-profile.spec.js` | Display name and password change (old password rejected, restored in finally) | +| `18a-realtime.spec.js` | Users page updates over the realtime socket without a reload | +| `20-activity.spec.js` | Activity rows for real actions, failed deploy shows its error inline, env change summary, purge | +| `20-export-import.spec.js` | Export, import as a clone that reaches running, overwrite import with preview | +| `20-security-validation.spec.js` | Compose security wiring (violations reported, nothing written), validate-compose matches deploy, app name rules | +| `21-user-validation.spec.js` | Duplicate username/email, short password, own-role and self-delete guards change nothing | +| `22-env-and-endpoints.spec.js` | Env round trip; IP allowlist enforced by the proxy (deny, then allow, then cleared) | +| `23-rollback-and-restore.spec.js` | Rollback switches the running image and routes traffic; roll forward again | +| `24-multi-user-isolation.spec.js` | Viewer session cannot mutate even a granted app | +| `25-edge-cases.spec.js` | Login is recorded in the activity log; metric prune deletes only old rows | +| `26-tls-ssl.spec.js` | **Isolated server**, `tls.mode=local`: internal-CA cert on one endpoint, uploaded custom cert on another | +| `26a-config-recovery.spec.js` | **Isolated server**: DB wiped, restart rebuilds users, keys, webhooks, registries and per-app config from the config files | +| `26b-gitsync.spec.js` | **Isolated server + bare git repo** (`E2E_GITSYNC=1`, on in CI): dashboard changes pushed, remote changes applied, conflict while the remote is offline | +| `27-backup-s3.spec.js` | **MinIO fixture**: S3 roundtrip, presigned download checksum, bad credentials, retention prunes objects and runs | +| `28-db-strategies.spec.js` | MySQL, MongoDB, Redis, SQLite backup/restore roundtrips against real containers | +| `29a-ratelimit.spec.js` | **Isolated server**: exact allow/deny sequence, `Retry-After`, per-key buckets, recovery after the window | +| `29b-private-registry.spec.js` | **registry:2.8.3 fixture**: deploy from a private registry; removing creds fails with an auth error | +| `30-app-templates-ui.spec.js` | Every app template validates (validate-compose and `docker compose config`); multi-endpoint templates in Quick test mode | +| `95-archive.spec.js` | Removing an app folder archives the app (containers and route gone); purge | +| `99-cleanup.spec.js` | UI delete removes containers, folder and API entry; API delete of a multi-service app | +| `templates-deploy-all.spec.js` | Opt-in (`E2E_TEMPLATES=1`, Templates workflow): deploys every app template and probes it over HTTP | + +Template image availability is checked by `scripts/check-template-images.mjs` in the Templates workflow (on template changes and weekly), not in the per-push suite. ## Test Fixtures @@ -71,57 +82,52 @@ DB strategy fixtures (used by `28-db-strategies.spec.js`, deployed per-describe) - **compose-mysql.yml** - mysql:8 with root+user accounts - **compose-mongo.yml** - mongo:7 with root auth - **compose-redis.yml** - redis:7-alpine with volume at /data -- **compose-sqlite.yml** - nginx with `simpledeploy.backup.strategy=sqlite` label and a seeded sqlite DB +- **compose-sqlite.yml** - `gitea/gitea` image (ships sqlite3) with `simpledeploy.backup.strategy=sqlite` and a seeded sqlite DB; no package installs at test time -Security fixture compose files in `fixtures/security/` are used for validation tests (never deployed): -- **compose-privileged.yml**, **compose-host-network.yml**, **compose-host-pid.yml**, **compose-host-ipc.yml** - dangerous runtime options -- **compose-dangerous-caps.yml** - SYS_ADMIN capability -- **compose-dangerous-volumes.yml** - docker socket mount -- **compose-path-traversal.yml** - path traversal in volume source +`fixtures/helper-images.yml` is never deployed: it lists the images helpers run directly (MinIO, registry) so the mirror workflow copies them. ## External-system fixtures (containers managed by tests, not simpledeploy) -Some specs spin up their own fixture containers via docker CLI and tear them down in `afterAll`. They're NOT managed by simpledeploy. +Some specs spin up their own fixture containers via docker CLI and tear them down in `afterAll`. They're NOT managed by simpledeploy. With `E2E_USE_MIRROR=1` (CI) their images come from the GHCR mirror (`helpers/images.js`). - **MinIO** (`helpers/minio.js`, used by `27-backup-s3.spec.js`): runs `pgsty/silo` (maintained drop-in MinIO fork; official `minio/minio` images are gone) pinned to the same tag as the MinIO templates, on a random localhost port, and creates a bucket via the `mc` client bundled in that image. Use for testing the S3 target without real AWS credentials. Returns `{endpoint, accessKey, secretKey, bucket, mc(), listObjects(), stop()}`. -- **Registry** (`helpers/registry.js`, used by `29b-private-registry.spec.js`) — runs `registry:2` with htpasswd auth on a random localhost port. Helper `pushImage(reg, from, toName)` handles pull→tag→login→push→logout. `startRegistry()` returns `{host, user, pass, stop()}`. Docker Desktop treats `127.0.0.0/8` as insecure by default; Linux Docker does too, so plain HTTP to `localhost:` works. +- **Registry** (`helpers/registry.js`, used by `29b-private-registry.spec.js`): runs `registry:2.8.3` with htpasswd auth (the file is copied in with `docker cp`, since macOS temp dirs are not visible to colima) on a random localhost port. Helper `pushImage(reg, from, toName)` handles pull→tag→login→push→logout. `startRegistry()` returns `{host, user, pass, stop()}`. Docker Desktop treats `127.0.0.0/8` as insecure by default; Linux Docker does too, so plain HTTP to `localhost:` works. ## Helpers ### `helpers/auth.js` ```js -import { loginAsAdmin, login, logout, getState, TEST_ADMIN } from '../helpers/auth.js'; - -// login as the test admin -await loginAsAdmin(page); - -// login as specific user -await login(page, 'username', 'password'); +import { loginAsAdmin, login, loginViaUI, logout, getState, TEST_ADMIN } from '../helpers/auth.js'; -// get server URL and config +await loginAsAdmin(page); // API login (shares the page's cookies), then opens the dashboard +await login(page, 'username', 'password'); // same, as another user +await loginViaUI(page); // through the login form: only for specs testing the form (02) const { baseURL, port } = getState(); ``` +Logging out revokes every session of that user (token version bump), including the cookie cached by `helpers/api.js`; call `apiLogin` again after a logout. + ### `helpers/api.js` Direct API calls (no browser), useful for setup/teardown: ```js -import { apiRequest, apiLogin, waitForAppStatus } from '../helpers/api.js'; +import { apiRequest, apiLogin, waitForAppStatus, waitForDeployDone } from '../helpers/api.js'; await apiLogin('admin', 'password'); await apiRequest('DELETE', '/api/apps/my-app'); await waitForAppStatus('my-app', 'running', 60_000); +await waitForDeployDone('my-app'); // a redeploy keeps status "running"; this waits for it to finish ``` ### `helpers/server.js` -Server lifecycle. `startServer` is exported so individual specs can spin up their own isolated server (used by `26-tls-ssl`, `29a-ratelimit`, `29b-private-registry`). +Server lifecycle. The server config is written to `data_dir/server-config.yaml` (`data_dir/config.yml` is SimpleDeploy's own global config file). Specs that need their own server use `helpers/isolated.js` (`26`, `26a`, `29a`, `29b`, `03c`). - `buildBinary()` - runs `make build` (used only by global-setup) - `getBinaryPath()` - returns the already-built binary path without rebuilding -- `startServer(binPath, overrides?)` - starts on random ports, waits for health. Overrides: `{proxyPort, dataDir, appsDir, tlsMode, ratelimit}`. `ratelimit` is `{requests, window, burst, by}` and applies to the auth/login rate limiter. +- `startServer(binPath, overrides?)` - starts on random ports, waits for health. Overrides: `{proxyPort, dataDir, appsDir, tlsMode, ratelimit, recipesIndexURL}`. `ratelimit` applies to the management API limiter only. - `stopServer(server)` - kills process, cleans temp dirs ### `helpers/docker.js` @@ -143,7 +149,7 @@ Mirror of `psql()` for the other DB strategies, used by `28-db-strategies.spec.j - `redisCmd(container, ...args)` — `docker exec ... redis-cli ` - `sqlite3Eval(container, dbPath, sql)` — `docker exec ... sqlite3 ` -All log to `/tmp/e2e-dbclients-trace.log` for debugging. None use `sh -c`. +None use `sh -c`. Errors include the full stdout/stderr. ### `helpers/proxy.js` @@ -152,6 +158,7 @@ Reverse-proxy HTTP helpers. `fetchViaProxy(host, path, opts)` wraps curl under t - `fetchViaProxy(host, path, opts)` — returns `{status, ok, text(), json()}` - `curlHTTPS(host, port, path, opts)` — with `-k` (accept self-signed), for `26-tls-ssl` - `openSSLGetCert(host, port)` — parses `openssl s_client` output to a `{subject, issuer, notBefore, notAfter}` object +- `waitForProxyStatus(host, status, {timeoutMs, bodyContains, path, proxyURL})` — polls until the route answers; use instead of sleeping after an endpoint change ### `helpers/webhook.js` @@ -162,6 +169,7 @@ const receiver = await startWebhookReceiver(); // receiver.url → http://127.0.0.1: // receiver.received → array of {method, path, headers, body, at} // receiver.waitFor(predicate, timeoutMs) → resolves when a matching POST lands +// receiver.onPath(path), receiver.countWhere(predicate): per-path assertions (probe/control rules) // receiver.clear(), receiver.stop() ``` @@ -169,10 +177,29 @@ const receiver = await startWebhookReceiver(); Direct sqlite queries against simpledeploy's DB (different from `dbclients.js` which targets app containers). -- `sqliteQuery(sql)`, `sqliteExec(sql)`, `tableCount(table)` +- `sqliteQuery(sql)`, `sqliteExec(sql)` - `getAppId(slug)` — resolves an app slug to the FK used in metrics/alert tables -- `insertMetricPoint({appSlug, cpu, memoryMb, tsSec, tier})` — injects synthetic metrics for deterministic alert evaluation -- `injectHighCPUWindow(appSlug)` — shortcut that inserts 10 high-CPU points spread over the last minute +- `replaceMetricsAtomic(appSlug, {...})` — swaps an app's metric points in one transaction (deterministic alert input) + +### `helpers/metrics.js` + +`startMetricRefresher(slug, high)` keeps a synthetic CPU window in place for the alert evaluator (first write throws; later errors surface in waits), `deleteAppMetrics(slug)`, `waitForCollectorCycle()` (lets an in-flight collector write land before the test starts). + +### `helpers/backups.js` + +`createBackupConfig`, `triggerAndWait`, `pollRunStatus`, `waitForNewRunForConfig`, `deleteAppConfigsFor`, `localBackupPath`, `waitForNextSecond` (backup filenames have 1s resolution). + +### `helpers/users.js` + +`findUser`, `deleteUserByName`, `waitForDeployEvent` / `latestDeployEventId` (wait for the deploy a 202 started), `waitForAppSettled` (no deploy running, app running, stored compose hash matches the file). + +### `helpers/isolated.js` + +`startIsolatedServer(opts)` returns `{server, cookie, api}` for a spec-owned server; `waitForAppStatusAt`, `loginAt`, `deployAt`, `stopGracefully` (SIGTERM and wait, so pending config writes flush), `stopIsolatedServer(server, logName)` (keeps the log in `test-results/`). + +### `helpers/recipes.js` + +Serves a one-recipe community catalog on 127.0.0.1 for `03c`. ### `helpers/minio.js` @@ -259,10 +286,24 @@ await page.waitForSelector('aside', { timeout: 15_000 }); const closeBtn = page.getByRole('button', { name: /close/i }); await expect(closeBtn).toBeVisible({ timeout: 60_000 }); -// wait for toast notification -await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 5_000 }); +// wait for a specific toast, then check the server state it claims +await expect(page.getByRole('alert')).toHaveText('Profile updated'); ``` +## Test design rules + +A test earns its place only if it would fail when the feature it names breaks, and only if Go tests or vitests cannot show the same thing (those take seconds; this suite takes minutes). + +- Assert the resulting server state (API, Docker, proxy, files), not just a toast or a visible heading. A toast check also needs its exact text: `[role="alert"]` matches error toasts too. +- No conditional assertions: no `if (await x.isVisible())`, `.catch(() => false)` guards, or try/catch that swallows a failure. If an element may be missing, the test is wrong. +- No fixed sleeps when a condition can be polled (`expect.poll`, `waitForResponse`, `waitForProxyStatus`, `waitForAppStatus`, `waitForDeployDone`). +- Exact status codes, never ranges like `< 500` or `[200, 404]`. +- Scope text matches to `main`, a table or a dialog. The activity sidebar is always in the DOM and lists app names, so page-wide `getByText(...).first()` passes on the wrong element. +- Await background work: after a 202, wait for the app to settle before the test ends, or the next spec inherits a redeploy. +- Put cleanup that later specs depend on in `finally`/`afterAll`. Restore anything shared (admin password, `e2e-nginx` env, allowlists). +- A test for unfixed product behavior asserts the correct behavior and fails until the fix lands. Never skip or loosen it to get green. +- Do not test pure rendering or client-side logic here; write a vitest. + ## Server Configuration The test server starts with these settings (see `helpers/server.js`): @@ -272,10 +313,12 @@ The test server starts with these settings (see `helpers/server.js`): | `tls.mode` | `"off"` | No certs needed, avoids HTTPS complexity | | `management_port` | random | Prevents port conflicts | | `master_secret` | fixed string | Deterministic JWT/encryption for tests | -| `ratelimit.requests` | 10000 | Prevents login failures across 91 tests | +| `login_ratelimit.requests` | 10000 | Tests log in many times from 127.0.0.1 | | `data_dir` / `apps_dir` | temp dirs | Clean state per run, auto-cleaned | | `log_buffer_size` | 100 | Smaller buffer for test performance | +Tick intervals are shortened through env (alert evaluation 2s, metrics flush 1s, status refresh 5s). + ## Debugging Failures **View the HTML report:** @@ -390,17 +433,10 @@ Don't rely on real external services (S3, Telegram, Discord, Let's Encrypt). Sta - Tests cannot run individually. They depend on prior test state (setup -> deploy -> actions). - Docker image pulls on first run can be slow (~1-2min for nginx/redis/postgres). -- The backup trigger test accepts any completion status (success or failed) since pg_dump may fail if postgres hasn't fully initialized. - Svelte 5 collapsible sections sometimes don't respond to Playwright's click. One test works around this. ## CI Integration -```yaml -# GitHub Actions example -- name: E2E Tests - run: make e2e - env: - DOCKER_HOST: unix:///var/run/docker.sock -``` - -Requires a runner with Docker. The test suite is self-contained: builds the binary, manages its own server, cleans up after itself. +- `.github/workflows/ci.yml` runs `make e2e` on every push to `main` with `E2E_USE_MIRROR=1` (images from the GHCR mirror, no Docker Hub limits) and `E2E_GITSYNC=1`. It uploads `playwright-report/` and `test-results/` (failure traces plus `server.log`, which global teardown copies out before deleting the temp dirs). +- `.github/workflows/templates.yml` runs the template image check and the `templates-deploy-all` matrix when templates or the compose/deployer code change, and weekly. +- Global teardown removes compose stacks left behind by failed specs (only stacks whose folder is under an `sd-e2e-*` temp dir). diff --git a/e2e/fixtures/compose-sqlite.yml b/e2e/fixtures/compose-sqlite.yml index 2de9625b..f2e06ff8 100644 --- a/e2e/fixtures/compose-sqlite.yml +++ b/e2e/fixtures/compose-sqlite.yml @@ -1,20 +1,19 @@ services: - web: - image: nginx:alpine - ports: - - "8097:80" - command: > - sh -c " - apk add --no-cache sqlite && - mkdir -p /data && - sqlite3 /data/test.db \"CREATE TABLE IF NOT EXISTS e2e_items(id INTEGER PRIMARY KEY, v TEXT);\" && - nginx -g 'daemon off;' - " + app: + # Gitea itself does not run: the image is used because it ships sqlite3 + # and busybox (sh, tar, cp) and is already mirrored for the Gitea + # template, so the fixture needs no package download at test time. + image: gitea/gitea:1.26 + entrypoint: + - sh + - -c + - >- + sqlite3 /data/test.db "CREATE TABLE IF NOT EXISTS e2e_items(id INTEGER PRIMARY KEY, v TEXT);" && + trap 'exit 0' TERM INT && + while :; do sleep 1; done volumes: - sqlitedata:/data labels: simpledeploy.backup.strategy: "sqlite" - simpledeploy.endpoints.0.port: "80" - simpledeploy.endpoints.0.tls: "off" volumes: sqlitedata: diff --git a/e2e/fixtures/helper-images.yml b/e2e/fixtures/helper-images.yml new file mode 100644 index 00000000..402af66b --- /dev/null +++ b/e2e/fixtures/helper-images.yml @@ -0,0 +1,9 @@ +# Not deployed. Lists images that e2e helpers run directly (helpers/minio.js, +# helpers/registry.js) so scripts/list-mirror-images.mjs mirrors them to GHCR. +services: + minio: + image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z + registry: + image: registry:2.8.3 + registry-push-source: + image: nginx:alpine diff --git a/e2e/fixtures/security/compose-dangerous-caps.yml b/e2e/fixtures/security/compose-dangerous-caps.yml deleted file mode 100644 index 1eee65c4..00000000 --- a/e2e/fixtures/security/compose-dangerous-caps.yml +++ /dev/null @@ -1,5 +0,0 @@ -services: - web: - image: nginx:alpine - cap_add: - - SYS_ADMIN diff --git a/e2e/fixtures/security/compose-dangerous-volumes.yml b/e2e/fixtures/security/compose-dangerous-volumes.yml deleted file mode 100644 index 0b865276..00000000 --- a/e2e/fixtures/security/compose-dangerous-volumes.yml +++ /dev/null @@ -1,5 +0,0 @@ -services: - web: - image: nginx:alpine - volumes: - - /var/run/docker.sock:/var/run/docker.sock diff --git a/e2e/fixtures/security/compose-host-ipc.yml b/e2e/fixtures/security/compose-host-ipc.yml deleted file mode 100644 index 7d4bd315..00000000 --- a/e2e/fixtures/security/compose-host-ipc.yml +++ /dev/null @@ -1,4 +0,0 @@ -services: - web: - image: nginx:alpine - ipc: host diff --git a/e2e/fixtures/security/compose-host-network.yml b/e2e/fixtures/security/compose-host-network.yml deleted file mode 100644 index ac542060..00000000 --- a/e2e/fixtures/security/compose-host-network.yml +++ /dev/null @@ -1,4 +0,0 @@ -services: - web: - image: nginx:alpine - network_mode: host diff --git a/e2e/fixtures/security/compose-host-pid.yml b/e2e/fixtures/security/compose-host-pid.yml deleted file mode 100644 index cbc86be4..00000000 --- a/e2e/fixtures/security/compose-host-pid.yml +++ /dev/null @@ -1,4 +0,0 @@ -services: - web: - image: nginx:alpine - pid: host diff --git a/e2e/fixtures/security/compose-path-traversal.yml b/e2e/fixtures/security/compose-path-traversal.yml deleted file mode 100644 index 2d3d467f..00000000 --- a/e2e/fixtures/security/compose-path-traversal.yml +++ /dev/null @@ -1,7 +0,0 @@ -services: - web: - image: nginx:alpine - volumes: - - type: bind - source: ../../etc/passwd - target: /etc/passwd diff --git a/e2e/fixtures/security/compose-privileged.yml b/e2e/fixtures/security/compose-privileged.yml deleted file mode 100644 index c1be7193..00000000 --- a/e2e/fixtures/security/compose-privileged.yml +++ /dev/null @@ -1,4 +0,0 @@ -services: - web: - image: nginx:alpine - privileged: true diff --git a/e2e/global-teardown.js b/e2e/global-teardown.js index 230a4ba5..538c2423 100644 --- a/e2e/global-teardown.js +++ b/e2e/global-teardown.js @@ -1,7 +1,38 @@ -import { readFileSync, rmSync, existsSync } from 'fs'; +import { readFileSync, rmSync, existsSync, mkdirSync, copyFileSync } from 'fs'; +import { execFileSync } from 'child_process'; +import { tmpdir } from 'os'; import { join } from 'path'; const STATE_FILE = join(import.meta.dirname, '.e2e-state.json'); +const LOG_DIR = join(import.meta.dirname, 'test-results'); + +// Compose projects deployed by e2e servers (shared and isolated) live under +// temp dirs named sd-e2e-*. Only those are removed, never other stacks on +// the machine. Specs clean up after themselves; this catches what a failed +// spec leaked. +function removeLeakedStacks() { + let out; + try { + out = execFileSync('docker', [ + 'ps', '-a', '--filter', 'label=com.docker.compose.project', + '--format', '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}', + ], { encoding: 'utf-8' }); + } catch { + return; + } + const prefix = join(tmpdir(), 'sd-e2e-'); + const projects = new Set(); + for (const line of out.split('\n')) { + const [project, dir] = line.split('\t'); + if (project && dir && (dir.startsWith(prefix) || dir.startsWith(`/private${prefix}`))) projects.add(project); + } + for (const p of projects) { + try { + execFileSync('docker', ['compose', '-p', p, 'down', '-v', '--remove-orphans'], { stdio: 'ignore', timeout: 60_000 }); + console.log(`[e2e] Removed leaked stack ${p}`); + } catch {} + } +} export default async function globalTeardown() { if (!existsSync(STATE_FILE)) return; @@ -14,6 +45,14 @@ export default async function globalTeardown() { try { process.kill(state.pid, 'SIGKILL'); } catch {} } catch {} + // Keep the server log next to the failure traces (CI uploads test-results/). + try { + mkdirSync(LOG_DIR, { recursive: true }); + copyFileSync(state.logPath, join(LOG_DIR, 'server.log')); + } catch {} + + removeLeakedStacks(); + if (!process.env.SIMPLEDEPLOY_KEEP) { for (const dir of [state.dataDir, state.appsDir]) { try { rmSync(dir, { recursive: true, force: true }); } catch {} diff --git a/e2e/helpers/api.js b/e2e/helpers/api.js index c1d9aa98..a6ad3e42 100644 --- a/e2e/helpers/api.js +++ b/e2e/helpers/api.js @@ -119,3 +119,18 @@ export async function apiRequestAt(baseURL, method, path, body, sessionCookie) { try { data = JSON.parse(text); } catch { data = text; } return { status: res.status, data, ok: res.ok, setCookie }; } + +// waitForDeployDone polls until no deploy, restart or pull is in flight for +// slug and returns the app. Call it once the deploy has visibly started: +// unlike waitForAppStatus it cannot pass on the pre-deploy status. +export async function waitForDeployDone(slug, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + const res = await apiRequest('GET', `/api/apps/${slug}`); + last = res.data; + if (res.ok && res.data && res.data.deploying === false) return res.data; + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`App ${slug} still deploying after ${timeoutMs}ms (last: ${JSON.stringify(last)})`); +} diff --git a/e2e/helpers/auth.js b/e2e/helpers/auth.js index 63fcd742..d14669a6 100644 --- a/e2e/helpers/auth.js +++ b/e2e/helpers/auth.js @@ -14,7 +14,9 @@ export const TEST_ADMIN = { email: 'e2e@test.local', }; -export async function login(page, username, password) { +// loginViaUI signs in through the login form. Use it where the form itself +// is under test; everything else uses login(), which is faster. +export async function loginViaUI(page, username, password) { const state = getState(); await page.goto(`${state.baseURL}/#/login`); // Wait for login form to finish loading (setupStatus check) @@ -28,6 +30,18 @@ export async function login(page, username, password) { await page.waitForSelector('aside:not([data-testid="activity-sidebar"])', { timeout: 15_000 }); } +// login signs in through the API (page.request shares the browser context's +// cookie jar), then opens the dashboard. +export async function login(page, username, password) { + const state = getState(); + const res = await page.request.post(`${state.baseURL}/api/auth/login`, { + data: { username: username || TEST_ADMIN.username, password: password || TEST_ADMIN.password }, + }); + if (!res.ok()) throw new Error(`login failed: ${res.status()} ${await res.text()}`); + await page.goto(`${state.baseURL}/#/`); + await page.waitForSelector('aside:not([data-testid="activity-sidebar"])', { timeout: 15_000 }); +} + export async function loginAsAdmin(page) { await login(page, TEST_ADMIN.username, TEST_ADMIN.password); } diff --git a/e2e/helpers/backups.js b/e2e/helpers/backups.js new file mode 100644 index 00000000..ce3258b6 --- /dev/null +++ b/e2e/helpers/backups.js @@ -0,0 +1,108 @@ +import { expect } from '@playwright/test'; +import { existsSync } from 'fs'; +import { apiRequest } from './api.js'; +import { getState } from './auth.js'; + +// Backup config/run helpers shared by the backup specs (12, 13, 27, 28). + +const CONFIG_DEFAULTS = { + target: 'local', + schedule_cron: '0 0 1 1 *', // yearly; never fires during a run + target_config_json: '', + retention_mode: 'count', + retention_count: 5, + verify_upload: false, + pre_hooks: '', + post_hooks: '', + paths: '', +}; + +// createBackupConfig POSTs a config with test defaults and returns its id. +export async function createBackupConfig(slug, fields) { + const res = await apiRequest('POST', `/api/apps/${slug}/backups/configs`, { ...CONFIG_DEFAULTS, ...fields }); + expect(res.status, `create ${fields.strategy} config for ${slug}: ${JSON.stringify(res.data)}`).toBe(201); + expect(res.data.id).toBeGreaterThan(0); + return res.data.id; +} + +export async function listRuns(slug) { + const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); + expect(res.status, `list runs for ${slug}`).toBe(200); + return Array.isArray(res.data) ? res.data : []; +} + +export async function runsForConfig(slug, configId) { + return (await listRuns(slug)).filter((r) => r.backup_config_id === configId); +} + +export async function pollRunStatus(slug, runId, desired, timeoutMs) { + const want = Array.isArray(desired) ? desired : [desired]; + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + last = (await listRuns(slug)).find((r) => r.id === runId) || last; + if (last && want.includes(last.status)) return last; + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`run ${runId} did not reach ${want.join('|')} within ${timeoutMs}ms (last=${JSON.stringify(last)})`); +} + +// waitForNewRunForConfig returns the first run of configId whose id is not +// in knownIds. Runs are listed per config, so data[0] is not the newest. +export async function waitForNewRunForConfig(slug, configId, knownIds, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const fresh = (await runsForConfig(slug, configId)).filter((r) => !knownIds.has(r.id)); + if (fresh.length) return fresh.sort((a, b) => b.id - a.id)[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no new run for config ${configId} in ${slug} within ${timeoutMs}ms`); +} + +// triggerAndWait runs configId now and returns the finished run +// (status success or failed). +export async function triggerAndWait(slug, configId, timeoutMs = 180_000) { + const known = new Set((await runsForConfig(slug, configId)).map((r) => r.id)); + const trig = await apiRequest('POST', `/api/backups/configs/${configId}/run`); + expect(trig.status, `trigger config ${configId}`).toBe(202); + const run = await waitForNewRunForConfig(slug, configId, known); + return pollRunStatus(slug, run.id, ['success', 'failed'], timeoutMs); +} + +export async function deleteAppConfigsFor(slug) { + const res = await apiRequest('GET', `/api/apps/${slug}/backups/configs`); + if (!res.ok || !Array.isArray(res.data)) return; + for (const c of res.data) { + await apiRequest('DELETE', `/api/backups/configs/${c.id}`); + } +} + +// localBackupPath resolves a local-target run's file. run.file_path is the +// bare filename; the server stores it under /backups. +export function localBackupPath(run) { + return `${getState().dataDir}/backups/${String(run.file_path).split('/').pop()}`; +} + +export function localBackupExists(run) { + return !!run.file_path && existsSync(localBackupPath(run)); +} + +// waitForNextSecond resolves once the wall clock is past the second of ts. +// Backup filenames have 1s resolution, so a run triggered after this gets a +// distinct file. +export async function waitForNextSecond(ts) { + const sec = Math.floor(Date.parse(ts) / 1000); + while (Math.floor(Date.now() / 1000) <= sec) { + await new Promise((r) => setTimeout(r, 50)); + } +} + +// readOr returns fn() or fallback when it throws (e.g. a table not restored +// yet). For use inside expect.poll. +export function readOr(fn, fallback = '') { + try { + return fn(); + } catch { + return fallback; + } +} diff --git a/e2e/helpers/db.js b/e2e/helpers/db.js index c4b598ed..024bec50 100644 --- a/e2e/helpers/db.js +++ b/e2e/helpers/db.js @@ -30,39 +30,12 @@ export function sqliteQuery(sql) { try { return JSON.parse(out); } catch { return []; } } -export function tableCount(table) { - const out = sqliteExec(`SELECT COUNT(*) FROM ${table};`); - return Number(out); -} - export function getAppId(appSlug) { const rows = sqliteQuery(`SELECT id FROM apps WHERE slug='${appSlug}';`); if (!rows.length) throw new Error(`app ${appSlug} not found`); return rows[0].id; } -export function insertMetricPoint({ appSlug, cpu, memoryMb, tsSec, tier = 'raw' }) { - const ts = tsSec || Math.floor(Date.now() / 1000); - const appId = getAppId(appSlug); - const mem = Math.round((memoryMb || 0) * 1024 * 1024); - sqliteExec( - `INSERT INTO metrics (app_id, container_id, ts, tier, cpu_pct, mem_bytes, mem_limit) VALUES (${appId}, 'e2e-fake', ${ts}, '${tier}', ${cpu}, ${mem}, ${mem * 4});`, - ); - return appId; -} - -export function injectHighCPUWindow(appSlug, durationSec = 120, cpuPct = 95) { - const now = Math.floor(Date.now() / 1000); - for (let i = 0; i < 10; i++) { - insertMetricPoint({ - appSlug, - cpu: cpuPct, - memoryMb: 100, - tsSec: now - (i * 10), - }); - } -} - // Atomic replace: delete collector-written points for a slug and insert a // fresh window in one transaction. Needed when a fast evaluator tick (2s in // e2e) would otherwise land between DELETE and the first INSERT and flip the diff --git a/e2e/helpers/dbclients.js b/e2e/helpers/dbclients.js index a775a3f3..eb306aec 100644 --- a/e2e/helpers/dbclients.js +++ b/e2e/helpers/dbclients.js @@ -3,16 +3,6 @@ // matching the pattern used by psql() in helpers/docker.js to avoid quoting hazards. import { execFileSync } from 'child_process'; -import { appendFileSync } from 'fs'; - -function trace(tag, container, payload, outOrErr) { - try { - appendFileSync( - '/tmp/e2e-dbclients-trace.log', - `[${tag} ${new Date().toISOString()}] container=${container}\nCMD: ${payload}\nOUT: ${JSON.stringify(outOrErr)}\n---\n`, - ); - } catch {} -} function runArgv(container, argv, stdinBuf) { const opts = { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }; @@ -22,12 +12,10 @@ function runArgv(container, argv, stdinBuf) { } try { const out = execFileSync('docker', argv, opts); - trace('OK', container, argv.join(' '), out); return out.trim(); } catch (e) { const stderr = e.stderr ? e.stderr.toString() : ''; const stdout = e.stdout ? e.stdout.toString() : ''; - trace('ERR', container, argv.join(' '), { status: e.status, stdout, stderr }); throw new Error( `docker exec failed container=${container} exitCode=${e.status}\nARGV: ${argv.join(' ')}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, ); diff --git a/e2e/helpers/docker.js b/e2e/helpers/docker.js index 30957237..e27af01a 100644 --- a/e2e/helpers/docker.js +++ b/e2e/helpers/docker.js @@ -68,8 +68,6 @@ export function countServiceReplicas(appSlug, serviceName) { ]).length; } -import { appendFileSync } from 'fs'; - export function psql(containerName, user, db, sql) { try { const out = execFileSync( @@ -77,16 +75,10 @@ export function psql(containerName, user, db, sql) { ['exec', containerName, 'psql', '-U', user, '-d', db, '-t', '-A', '-v', 'ON_ERROR_STOP=1', '-c', sql], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }, ); - try { - appendFileSync('/tmp/e2e-psql-trace.log', `[OK ${new Date().toISOString()}] container=${containerName}\nSQL: ${sql}\nOUT: ${JSON.stringify(out)}\n---\n`); - } catch {} return out.trim(); } catch (e) { const stderr = e.stderr ? e.stderr.toString() : ''; const stdout = e.stdout ? e.stdout.toString() : ''; - try { - appendFileSync('/tmp/e2e-psql-trace.log', `[ERR ${new Date().toISOString()}] container=${containerName} exitCode=${e.status}\nSQL: ${sql}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}\n---\n`); - } catch {} throw new Error( `psql failed container=${containerName} exitCode=${e.status} signal=${e.signal}\nSQL: ${sql}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, ); diff --git a/e2e/helpers/images.js b/e2e/helpers/images.js new file mode 100644 index 00000000..ebf1a1ce --- /dev/null +++ b/e2e/helpers/images.js @@ -0,0 +1,14 @@ +// Image refs for containers the tests start directly (outside SimpleDeploy, +// which rewrites its own deploys). With E2E_USE_MIRROR=1 (CI), Docker Hub +// refs go through the GHCR mirror, same naming as internal/mirror. Every +// ref used here must be listed in fixtures/helper-images.yml so the mirror +// workflow copies it. +export function mirrored(ref) { + if (process.env.E2E_USE_MIRROR !== '1') return ref; + let prefix = process.env.SIMPLEDEPLOY_IMAGE_MIRROR_PREFIX || 'ghcr.io/vazra/simpledeploy-mirror/'; + if (!prefix.endsWith('/')) prefix += '/'; + const first = ref.split('/')[0]; + const hasHost = ref.includes('/') && (first.includes('.') || first.includes(':') || first === 'localhost'); + if (hasHost && first !== 'docker.io') return ref; + return prefix + ref.replace(/^docker\.io\//, '').replace(/^library\//, ''); +} diff --git a/e2e/helpers/isolated.js b/e2e/helpers/isolated.js new file mode 100644 index 00000000..d57a16dc --- /dev/null +++ b/e2e/helpers/isolated.js @@ -0,0 +1,107 @@ +// Helpers for specs that boot their own SimpleDeploy server (TLS modes, rate +// limits, private registries, disaster recovery) instead of the shared one. +import { copyFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { startServer, getBinaryPath } from './server.js'; +import { apiRequestAt } from './api.js'; +import { TEST_ADMIN } from './auth.js'; + +const LOG_DIR = join(import.meta.dirname, '..', 'test-results'); + +// loginAt logs in against an isolated server and returns the session cookie. +export async function loginAt(baseURL, username = TEST_ADMIN.username, password = TEST_ADMIN.password) { + const res = await apiRequestAt(baseURL, 'POST', '/api/auth/login', { username, password }); + if (res.status !== 200 || !res.setCookie) { + throw new Error(`login as ${username} failed: ${res.status} ${JSON.stringify(res.data)}`); + } + return res.setCookie; +} + +function session(server, cookie) { + return (method, path, body) => apiRequestAt(server.baseURL, method, path, body, cookie); +} + +// startIsolatedServer boots a server on fresh ports (opts go to startServer), +// creates the admin via /api/setup (unless opts.setup === false) and logs in. +// api(method, path, body) is pre-bound to the server and session. If setup +// or login fails the server is stopped, keeping its log under opts.logName. +export async function startIsolatedServer(opts = {}) { + const { setup = true, admin = TEST_ADMIN, logName, ...serverOpts } = opts; + const server = await startServer(getBinaryPath(), serverOpts); + try { + if (setup) { + const res = await apiRequestAt(server.baseURL, 'POST', '/api/setup', { + username: admin.username, + password: admin.password, + display_name: admin.displayName, + email: admin.email, + }); + if (res.status !== 201) throw new Error(`setup failed: ${res.status} ${JSON.stringify(res.data)}`); + } + const cookie = await loginAt(server.baseURL, admin.username, admin.password); + return { server, cookie, api: session(server, cookie) }; + } catch (e) { + await stopIsolatedServer(server, logName); + throw e; + } +} + +// stopGracefully sends SIGTERM and resolves once the process exited, so +// shutdown work (sidecar flush, Caddy stop) has finished. SIGKILL after +// timeoutMs. Data dirs are left in place. +export function stopGracefully(server, timeoutMs = 20_000) { + const { proc } = server; + if (!isRunning(server)) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(() => proc.kill('SIGKILL'), timeoutMs); + proc.once('exit', () => { clearTimeout(timer); resolve(); }); + proc.kill('SIGTERM'); + }); +} + +export function isRunning(server) { + return server.proc.exitCode === null && server.proc.signalCode === null; +} + +// stopIsolatedServer stops the server, keeps its log as +// test-results/-server.log (when logName is given) and removes its +// temp dirs. +export async function stopIsolatedServer(server, logName) { + if (!server) return; + await stopGracefully(server); + if (logName) { + try { + mkdirSync(LOG_DIR, { recursive: true }); + copyFileSync(server.logPath, join(LOG_DIR, `${logName}-server.log`)); + } catch {} + } + for (const dir of [server.dataDir, server.appsDir]) { + rmSync(dir, { recursive: true, force: true }); + } +} + +// deployAt posts a compose file to /api/apps/deploy and asserts the 202. +export async function deployAt(api, name, composeYAML) { + const res = await api('POST', '/api/apps/deploy', { + name, + compose: Buffer.from(composeYAML).toString('base64'), + }); + if (res.status !== 202) throw new Error(`deploy ${name} failed: ${res.status} ${JSON.stringify(res.data)}`); + return res; +} + +// waitForAppStatusAt polls GET /api/apps/ on an isolated server until +// Status is one of `status` (string or array). Returns the app. +export async function waitForAppStatusAt(baseURL, cookie, slug, status, timeoutMs = 180_000) { + const want = Array.isArray(status) ? status : [status]; + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + const res = await apiRequestAt(baseURL, 'GET', `/api/apps/${slug}`, null, cookie); + last = res; + // store.App has no JSON tags; fields serialize as PascalCase. + if (res.status === 200 && want.includes(res.data.Status)) return res.data; + await new Promise((r) => setTimeout(r, 1_000)); + } + throw new Error(`app ${slug} did not reach ${want.join('|')} within ${timeoutMs}ms; last: ${last && last.status} ${JSON.stringify(last && last.data)}`); +} diff --git a/e2e/helpers/metrics.js b/e2e/helpers/metrics.js new file mode 100644 index 00000000..0f00cec0 --- /dev/null +++ b/e2e/helpers/metrics.js @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { sqliteExec, sqliteQuery, replaceMetricsAtomic } from './db.js'; + +// Synthetic metric helpers for driving the alert evaluator. + +export function deleteAppMetrics(slug) { + sqliteExec(`DELETE FROM metrics WHERE app_id = (SELECT id FROM apps WHERE slug='${slug}');`); +} + +// startMetricRefresher keeps a full window of CPU points for slug in the +// metrics table (replacing anything the collector wrote) every 5s, so the +// evaluator always sees a breaching (high) or healthy window. The first +// write throws on error; later errors are kept in lastError() so a waitFor +// timeout can report them. +export function startMetricRefresher(slug, { high }) { + let lastError = null; + const write = () => + replaceMetricsAtomic(slug, { cpu: high ? 95 : 5, memoryMb: high ? 100 : 50 }); + write(); + const handle = setInterval(() => { + try { + write(); + lastError = null; + } catch (e) { + lastError = e; + } + }, 5_000); + return { + stop: () => clearInterval(handle), + lastError: () => (lastError ? `metric refresher: ${lastError.message}` : ''), + }; +} + +// waitForCollectorCycle resolves once the collector has stored a system +// point stamped at or after sinceSec. Cycles run back to back and a slow one +// can store an app's points seconds after they were sampled (e.g. a last idle +// point for a container stopped mid-cycle); once a later cycle's point is in +// the table, every earlier point is too. +export async function waitForCollectorCycle(sinceSec, timeoutMs = 60_000) { + const latest = () => + Number(sqliteQuery("SELECT COALESCE(MAX(ts), 0) AS ts FROM metrics WHERE app_id IS NULL AND tier = 'raw';")[0].ts); + await expect.poll(latest, { timeout: timeoutMs, message: 'collector cycle after the stop' }).toBeGreaterThanOrEqual(sinceSec); +} diff --git a/e2e/helpers/minio.js b/e2e/helpers/minio.js index b368994f..881ff4dd 100644 --- a/e2e/helpers/minio.js +++ b/e2e/helpers/minio.js @@ -1,5 +1,6 @@ import { execFileSync, spawnSync } from 'child_process'; import net from 'net'; +import { mirrored } from './images.js'; // MinIO fixture helper. Starts a MinIO-compatible server in a docker container // on a random host port, waits for health, creates a bucket, and exposes @@ -10,7 +11,7 @@ import net from 'net'; // and minio/mc from Docker Hub and quay.io/minio/* now requires login. Silo // keeps the MINIO_* env vars, ports, /minio/health/* routes and ships `mc`. // Same pinned tag as the MinIO app/service templates. -const MINIO_IMAGE = 'pgsty/silo:RELEASE.2026-09-16T00-00-00Z'; +const MINIO_IMAGE = mirrored('pgsty/silo:RELEASE.2026-09-16T00-00-00Z'); export const MINIO_ROOT_USER = 'minioadmin'; export const MINIO_ROOT_PASSWORD = 'minioadmin123'; @@ -92,22 +93,16 @@ export async function startMinIO({ bucket = 'e2e-backups' } = {}) { const accessKey = MINIO_ROOT_USER; const secretKey = MINIO_ROOT_PASSWORD; - // mc runs the bundled client in a short-lived container, configured via env - // MC_HOST_local. Use host networking via host.docker.internal on mac/windows, - // or --add-host for linux. We pass the endpoint as http://host.docker.internal:PORT - // because mc runs inside docker. + // mc runs the client bundled in the MinIO container itself, against + // localhost:9000 inside that container. function mc(args, opts = {}) { - const mcHost = `http://${accessKey}:${secretKey}@host.docker.internal:${port}`; try { return execFileSync( 'docker', [ - 'run', '--rm', - '--add-host', 'host.docker.internal:host-gateway', - '-e', `MC_HOST_local=${mcHost}`, - '--entrypoint', 'mc', - MINIO_IMAGE, - ...args, + 'exec', + '-e', `MC_HOST_local=http://${accessKey}:${secretKey}@localhost:9000`, + containerName, 'mc', ...args, ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }, ); @@ -121,44 +116,16 @@ export async function startMinIO({ bucket = 'e2e-backups' } = {}) { // Create the bucket via mc. mc(['mb', '--ignore-existing', `local/${bucket}`]); + // listObjects returns every object under prefix, keys relative to it. + // An empty or missing prefix lists nothing; any other mc error throws. function listObjects(prefix = '') { - const target = prefix ? `local/${bucket}/${prefix}` : `local/${bucket}`; - // mc ls --json emits one JSON document per line. - let out; - try { - out = mc(['ls', '--json', target]); - } catch (e) { - // Empty listing can return non-zero on some mc versions; treat as empty. - if (/no such|not found/i.test(String(e.message))) return []; - // Fallback: try plain ls - try { - const plain = mc(['ls', target]); - return plain - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const parts = line.split(/\s+/); - return { key: parts[parts.length - 1], size: 0 }; - }); - } catch { - return []; - } - } - const entries = []; - for (const line of out.split('\n')) { - const s = line.trim(); - if (!s) continue; - try { - const obj = JSON.parse(s); - if (obj.key || obj.name) { - entries.push({ key: obj.key || obj.name, size: obj.size || 0 }); - } - } catch { - // ignore non-json lines - } - } - return entries; + const target = prefix ? `local/${bucket}/${prefix.replace(/\/?$/, '/')}` : `local/${bucket}`; + const out = mc(['ls', '--recursive', '--json', target]); + return out + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + .map((obj) => ({ key: obj.key, size: obj.size || 0 })); } function stop() { diff --git a/e2e/helpers/proxy.js b/e2e/helpers/proxy.js index 0b3ef804..0cb87489 100644 --- a/e2e/helpers/proxy.js +++ b/e2e/helpers/proxy.js @@ -36,6 +36,27 @@ export async function fetchViaProxy(hostHeader, path = '/', opts = {}) { }; } +// waitForProxyStatus polls host through the proxy until it answers +// wantStatus (and, if given, a body containing bodyContains). opts is passed +// to fetchViaProxy (e.g. proxyURL for an isolated server). +export async function waitForProxyStatus(host, wantStatus, { timeoutMs = 15_000, bodyContains = null, path = '/', ...opts } = {}) { + const deadline = Date.now() + timeoutMs; + let last = 0; + let lastBody = ''; + while (Date.now() < deadline) { + const r = await fetchViaProxy(host, path, opts); + last = r.status; + const body = await r.text(); + lastBody = body.slice(0, 80); + if (r.status === wantStatus && (!bodyContains || body.includes(bodyContains))) { + return { ...r, text: async () => body }; + } + await new Promise((res) => setTimeout(res, 300)); + } + const hint = bodyContains ? ` body~${JSON.stringify(bodyContains)}` : ''; + throw new Error(`host ${host} did not reach status ${wantStatus}${hint} within ${timeoutMs}ms (last=${last}, lastBody=${lastBody})`); +} + export function curlViaProxy(hostHeader, path = '/', opts = {}) { const state = getState(); const base = opts.proxyURL || state.proxyURL; diff --git a/e2e/helpers/recipes.js b/e2e/helpers/recipes.js new file mode 100644 index 00000000..f58a5103 --- /dev/null +++ b/e2e/helpers/recipes.js @@ -0,0 +1,62 @@ +import { createServer } from 'http'; + +// One-recipe community catalog served from 127.0.0.1, so specs do not depend +// on the live catalog. Pass indexURL as startServer's recipesIndexURL. +export const FIXTURE_RECIPE = { + id: 'e2e-static-site', + name: 'E2E Static Site', + description: 'Fixture recipe served by the E2E suite.', + compose: [ + 'services:', + ' web:', + ' image: nginx:alpine', + ' labels:', + ' simpledeploy.endpoints.0.domain: "recipe-e2e.local"', + ' simpledeploy.endpoints.0.port: "80"', + ' simpledeploy.endpoints.0.tls: "off"', + '', + ].join('\n'), + readme: '# E2E Static Site\n\nFixture README marker e2e-readme-7f3a.\n', +}; + +export async function startRecipesCatalog() { + let base = ''; + const r = FIXTURE_RECIPE; + const files = () => ({ + '/index.json': ['application/json', JSON.stringify({ + schema_version: 1, + generated_at: new Date().toISOString(), + recipes: [{ + id: r.id, + name: r.name, + category: 'web', + description: r.description, + tags: ['e2e'], + author: 'e2e', + compose_url: `${base}/recipes/${r.id}/compose.yml`, + readme_url: `${base}/recipes/${r.id}/README.md`, + schema_version: 1, + }], + })], + [`/recipes/${r.id}/compose.yml`]: ['text/plain', r.compose], + [`/recipes/${r.id}/README.md`]: ['text/plain', r.readme], + }); + + const server = createServer((req, res) => { + const f = files()[req.url]; + if (!f) { + res.writeHead(404); + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': f[0] }); + res.end(f[1]); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${server.address().port}`; + return { + indexURL: `${base}/index.json`, + stop: () => new Promise((resolve) => server.close(() => resolve())), + }; +} diff --git a/e2e/helpers/registry.js b/e2e/helpers/registry.js index e31cc129..f9c20258 100644 --- a/e2e/helpers/registry.js +++ b/e2e/helpers/registry.js @@ -10,6 +10,7 @@ import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import net from 'net'; +import { mirrored } from './images.js'; function getAvailablePort() { return new Promise((resolve, reject) => { @@ -38,7 +39,7 @@ function dockerStdin(args, input) { }); } -// startRegistry spins up a registry:2 container with htpasswd-based basic auth +// startRegistry spins up a registry:2.8.3 container with htpasswd-based basic auth // bound to a random localhost port. Returns { host, user, pass, container, stop() }. export async function startRegistry(opts = {}) { const user = opts.user || 'testuser'; @@ -50,25 +51,27 @@ export async function startRegistry(opts = {}) { const authDir = join(tmp, 'auth'); execFileSync('mkdir', ['-p', authDir]); - // Generate htpasswd entry via httpd image (avoids requiring htpasswd on host). - // httpd's htpasswd -Bbn prints to stdout. - const htpasswdLine = docker([ - 'run', '--rm', '--entrypoint', 'htpasswd', 'httpd:2', - '-Bbn', user, pass, - ]).trim(); + // Precomputed bcrypt entry (`htpasswd -Bbn testuser testpass123`) for the + // default creds avoids pulling an image just to hash a password. + const htpasswdLine = user === 'testuser' && pass === 'testpass123' + ? 'testuser:$2y$05$EZbMr1D2P.keiaO2HXX1I.QXSVo2ty1OaInfvxJJpVV/exwzpR5CW' + : docker(['run', '--rm', '--entrypoint', 'htpasswd', 'httpd:2', '-Bbn', user, pass]).trim(); writeFileSync(join(authDir, 'htpasswd'), htpasswdLine + '\n'); - // Run registry:2 with htpasswd auth. + // Run registry:2 with htpasswd auth. The file goes in with docker cp, not + // a bind mount: the temp dir is not shared with the Docker VM on colima, + // and registry:2 then generates its own htpasswd (every login 401s). docker([ - 'run', '-d', + 'create', '--name', container, '-p', `127.0.0.1:${port}:5000`, - '-v', `${authDir}:/auth`, '-e', 'REGISTRY_AUTH=htpasswd', '-e', 'REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm', '-e', 'REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd', - 'registry:2', + mirrored('registry:2.8.3'), ]); + docker(['cp', authDir, `${container}:/auth`]); + docker(['start', container]); const host = `localhost:${port}`; @@ -100,10 +103,11 @@ export async function startRegistry(opts = {}) { // logs in, pushes, then logs out. Returns the full remote image ref. export function pushImage(reg, fromImage, toName) { const target = `${reg.host}/${toName}`; - try { docker(['pull', fromImage]); } catch (e) { - throw new Error(`pull ${fromImage} failed: ${e.message}`); + const source = mirrored(fromImage); + try { docker(['pull', source]); } catch (e) { + throw new Error(`pull ${source} failed: ${e.message}`); } - docker(['tag', fromImage, target]); + docker(['tag', source, target]); try { dockerStdin( ['login', '-u', reg.user, '--password-stdin', reg.host], diff --git a/e2e/helpers/server.js b/e2e/helpers/server.js index 9d8bed39..c7756720 100644 --- a/e2e/helpers/server.js +++ b/e2e/helpers/server.js @@ -36,7 +36,9 @@ export async function startServer(binPath, overrides = {}) { const proxyPort = overrides.proxyPort || await getAvailablePort(); const dataDir = overrides.dataDir || mkdtempSync(join(tmpdir(), 'sd-e2e-data-')); const appsDir = overrides.appsDir || mkdtempSync(join(tmpdir(), 'sd-e2e-apps-')); - const configPath = join(dataDir, 'config.yml'); + // Not data_dir/config.yml: that path is SimpleDeploy's own global sidecar, + // which would overwrite this file (and a restart would read it back). + const configPath = join(dataDir, 'server-config.yaml'); const logPath = join(dataDir, 'server.log'); const tlsMode = overrides.tlsMode || 'off'; // ratelimit override only affects the auth/API rate limiter (cfg.RateLimit). @@ -71,7 +73,10 @@ export async function startServer(binPath, overrides = {}) { ` window: "60s"`, ].join('\n'); - writeFileSync(configPath, config); + // recipesIndexURL points the community catalog at a local fixture + // (helpers/recipes.js) instead of the live one. + const recipesLine = overrides.recipesIndexURL ? `\nrecipes_index_url: "${overrides.recipesIndexURL}"` : ''; + writeFileSync(configPath, config + recipesLine); console.log(`[e2e] Starting server on port ${port}...`); console.log(`[e2e] Data dir: ${dataDir}`); diff --git a/e2e/helpers/template-probes.js b/e2e/helpers/template-probes.js index 28a6a4da..b56a3055 100644 --- a/e2e/helpers/template-probes.js +++ b/e2e/helpers/template-probes.js @@ -14,17 +14,17 @@ // asserted via the status pill. // // `hostVar` is the key of the template variable that holds the domain to -// route by. Defaults to 'domain' if omitted. +// route by. Defaults to 'domain' if omitted. A probe without bodyIncludes +// also needs a non-empty body (or a Location on a redirect), since Caddy +// answers hosts without a route with an empty 200. export const templateProbes = { 'nginx-static': { - // Raw `docker compose up` on this template serves nginx's default - // welcome page because Docker auto-populates a new named volume from - // the image content on first mount. SimpleDeploy's deployer pre-creates - // the volume, which suppresses that copy, so the deployed app serves - // an empty 200. We only assert reachability here, not body content. + // Docker populates the new, empty web-content volume from the image on + // first mount (also when compose created it beforehand), so nginx serves + // its welcome page. probes: [ - { path: '/', statusMin: 200, statusMax: 404 }, + { path: '/', statusMin: 200, statusMax: 200, bodyIncludes: 'Welcome to nginx' }, ], }, @@ -108,7 +108,6 @@ export const templateProbes = { ], }, - 'woodpecker-ci': { probe: null, reason: 'Requires forge OAuth credentials (GitHub/Gitea/GitLab) to boot' }, 'webhook-tester': { probes: [ diff --git a/e2e/helpers/users.js b/e2e/helpers/users.js new file mode 100644 index 00000000..00ef326f --- /dev/null +++ b/e2e/helpers/users.js @@ -0,0 +1,54 @@ +import { createHash } from 'crypto'; +import { expect } from '@playwright/test'; +import { apiRequest } from './api.js'; + +export function b64(s) { + return Buffer.from(s).toString('base64'); +} + +// deleteUserByName removes a user left over from an earlier failed run. +export async function deleteUserByName(username) { + const list = await apiRequest('GET', '/api/users'); + expect(list.status).toBe(200); + const u = list.data.find((x) => x.username === username); + if (u) expect((await apiRequest('DELETE', `/api/users/${u.id}`)).status).toBe(200); +} + +export async function findUser(username) { + const list = await apiRequest('GET', '/api/users'); + expect(list.status).toBe(200); + return list.data.find((u) => u.username === username); +} + +// latestDeployEventId returns the newest deploy event id for an app (0 if none). +export async function latestDeployEventId(slug) { + const res = await apiRequest('GET', `/api/apps/${slug}/events`); + expect(res.status).toBe(200); + return res.data.reduce((max, e) => Math.max(max, e.id), 0); +} + +// waitForDeployEvent polls until the app records a deploy event newer than +// afterId (deploy, restart, rollback, ...) and returns it. Background work +// started by a 202 or an import ends with such an event. +export async function waitForDeployEvent(slug, afterId, timeoutMs = 90_000) { + let ev; + await expect.poll(async () => { + const res = await apiRequest('GET', `/api/apps/${slug}/events`); + ev = (res.data || []).filter((e) => e.id > afterId).sort((a, b) => b.id - a.id)[0]; + return ev ? ev.action : null; + }, { timeout: timeoutMs, intervals: [500] }).not.toBeNull(); + return ev; +} + +// waitForAppSettled polls until no deploy is in flight, the app runs, and the +// stored compose hash matches the file on disk. Editing compose labels (IP +// allowlist, endpoints) makes the watcher redeploy about a second later; a +// rollback or deploy started meanwhile is skipped. +export async function waitForAppSettled(slug, timeoutMs = 90_000) { + await expect.poll(async () => { + const app = await apiRequest('GET', `/api/apps/${slug}`); + const compose = await apiRequest('GET', `/api/apps/${slug}/compose`); + const hash = createHash('sha256').update(String(compose.data)).digest('hex'); + return { deploying: app.data.deploying, status: app.data.Status, hashMatches: app.data.ComposeHash === hash }; + }, { timeout: timeoutMs, intervals: [500] }).toEqual({ deploying: false, status: 'running', hashMatches: true }); +} diff --git a/e2e/helpers/webhook.js b/e2e/helpers/webhook.js index ee3ed6be..ad8495a6 100644 --- a/e2e/helpers/webhook.js +++ b/e2e/helpers/webhook.js @@ -39,15 +39,22 @@ export async function startWebhookReceiver() { setResponder: (fn) => (responder = fn), clear: () => (received.length = 0), stop: () => new Promise((r) => server.close(() => r())), - waitFor: async (predicate, timeoutMs = 20_000) => { + // waitFor resolves with the first request matching predicate. On + // timeout the error lists what did arrive, plus describe() if given. + waitFor: async (predicate, timeoutMs = 20_000, describe) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const match = received.find(predicate); if (match) return match; await new Promise((r) => setTimeout(r, 200)); } - throw new Error(`webhook not received matching predicate within ${timeoutMs}ms. got ${received.length} requests`); + const got = received.map((r) => `${r.path} ${r.rawBody.slice(0, 120)}`).join('; '); + const extra = describe ? describe() : ''; + throw new Error(`webhook not received matching predicate within ${timeoutMs}ms. got ${received.length} requests: [${got}] ${extra}`); }, + // onPath returns requests received on path (e.g. '/', '/probe'). + onPath: (path) => received.filter((r) => r.path === path), + countWhere: (predicate) => received.filter(predicate).length, }); }); }); diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 2e62008b..ef819e96 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -4,15 +4,12 @@ import { join } from 'path'; // Git-sync spec requires E2E_GITSYNC=1: spawns isolated server + bare git repo. const GITSYNC_SPEC = '**/26b-gitsync.spec.js'; -// Slow specs skipped when E2E_LITE=1. Each pulls large images, spawns -// auxiliary containers (MinIO, DB engines, local registry), or waits on -// long timing windows (webhook delivery). Trims ~15 min from the suite. +// Slow specs skipped when E2E_LITE=1. Each pulls large images or spawns +// auxiliary containers (MinIO, DB engines, local registry). const LITE_SKIP = [ - '**/13b-webhook-formats.spec.js', '**/27-backup-s3.spec.js', '**/28-db-strategies.spec.js', '**/29b-private-registry.spec.js', - '**/slow-*.spec.js', ]; // Template deploy-all matrix. Expensive (pulls ~20 images across @@ -49,7 +46,6 @@ export default defineConfig({ baseURL: `http://localhost:${process.env.SIMPLEDEPLOY_PORT || 8500}`, trace: 'retain-on-failure', screenshot: 'only-on-failure', - video: 'retain-on-failure', }, projects: [ { diff --git a/e2e/scripts/check-template-images.mjs b/e2e/scripts/check-template-images.mjs new file mode 100644 index 00000000..ebad2aed --- /dev/null +++ b/e2e/scripts/check-template-images.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Checks that every image referenced by an app template or service template +// resolves in its registry (`docker manifest inspect`, no layer pulls). +// Catches typos and yanked tags. Run by the Templates workflow on template +// changes and weekly; not part of the per-push E2E suite. +// +// node e2e/scripts/check-template-images.mjs + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { appTemplates } from '../../ui/src/lib/appTemplates.js'; +import { serviceTemplates } from '../../ui/src/lib/serviceTemplates.js'; + +const execFileP = promisify(execFile); +const CONCURRENCY = 4; +const ATTEMPTS = 4; +// Registry or network hiccups, not a verdict on the image. +const RATE_LIMIT_RE = /toomanyrequests|rate limit/i; +const TRANSIENT_RE = /toomanyrequests|rate limit|timeout|timed out|TLS handshake|connection reset|EOF|50[0-9] |Service Unavailable|Bad Gateway/i; + +function collectImages(node, out) { + if (node == null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const item of node) collectImages(item, out); + return; + } + for (const [k, v] of Object.entries(node)) { + if (k === 'image' && typeof v === 'string') out.add(v); + else collectImages(v, out); + } +} + +function allTemplateImages() { + const images = new Set(); + for (const tpl of appTemplates) collectImages(tpl.compose, images); + for (const svc of serviceTemplates) { + if (typeof svc?.config?.image === 'string') images.add(svc.config.image); + } + images.delete(''); + return [...images].sort(); +} + +async function inspect(img) { + let last = ''; + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + await execFileP('docker', ['manifest', 'inspect', img], { timeout: 60_000 }); + return null; + } catch (err) { + // A killed process (our timeout) has no stderr. + last = err?.killed ? 'timeout' : String(err?.stderr || err?.message || err).trim(); + if (!TRANSIENT_RE.test(last)) return last; + await new Promise((r) => setTimeout(r, 3_000 * attempt)); + } + } + return `transient errors on every attempt: ${last}`; +} + +const images = allTemplateImages(); +if (images.length === 0) { + console.error('no template images found'); + process.exit(1); +} +const failures = []; +let next = 0; +await Promise.all( + Array.from({ length: CONCURRENCY }, async () => { + while (next < images.length) { + const img = images[next++]; + const err = await inspect(img); + if (err) failures.push({ img, err }); + } + }), +); +const real = failures.filter((f) => !RATE_LIMIT_RE.test(f.err)); +if (real.length > 0) { + const lines = real.map((f) => ` - ${f.img}: ${f.err}`).sort().join('\n'); + console.error(`Unresolvable template images (${real.length}/${images.length}):\n${lines}`); + process.exit(1); +} +if (failures.length > 0) { + // Registry throttling says nothing about the images; flag it, don't fail. + console.log(`::warning::${failures.length} template image(s) not checked: registry rate limit`); + process.exit(0); +} +console.log(`All ${images.length} template images resolve.`); diff --git a/e2e/tests/00-template-images.spec.js b/e2e/tests/00-template-images.spec.js deleted file mode 100644 index e66d2a7a..00000000 --- a/e2e/tests/00-template-images.spec.js +++ /dev/null @@ -1,100 +0,0 @@ -// Fast sanity check: every image referenced by an app template or service -// template resolves in its registry via `docker manifest inspect`. Catches -// typos and yanked tags without actually pulling layers. Runs in both -// e2e-lite and the full suite. - -import { test, expect } from '@playwright/test'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { appTemplates } from '../../ui/src/lib/appTemplates.js'; -import { serviceTemplates } from '../../ui/src/lib/serviceTemplates.js'; - -const execFileP = promisify(execFile); - -function collectImages(node, out) { - if (node == null) return; - if (typeof node !== 'object') return; - if (Array.isArray(node)) { - for (const item of node) collectImages(item, out); - return; - } - for (const [k, v] of Object.entries(node)) { - if (k === 'image' && typeof v === 'string') out.add(v); - else collectImages(v, out); - } -} - -function allTemplateImages() { - const images = new Set(); - for (const tpl of appTemplates) collectImages(tpl.compose, images); - for (const svc of serviceTemplates) { - if (typeof svc?.config?.image === 'string') images.add(svc.config.image); - } - images.delete(''); - return [...images].sort(); -} - -const CONCURRENCY = 4; -const RATE_LIMIT_RE = /toomanyrequests|rate limit/i; - -async function inspectOnce(img) { - await execFileP('docker', ['manifest', 'inspect', img], { timeout: 30_000 }); -} - -async function inspectWithRetry(img) { - // Up to 3 attempts with backoff; retry on rate-limit responses so a - // transient Docker Hub throttle does not fail the whole suite. - for (let attempt = 1; attempt <= 3; attempt++) { - try { - await inspectOnce(img); - return { img, ok: true }; - } catch (err) { - const msg = String(err?.stderr || err?.message || err); - if (attempt < 3 && RATE_LIMIT_RE.test(msg)) { - await new Promise((r) => setTimeout(r, 2_000 * attempt)); - continue; - } - return { img, ok: false, err: msg }; - } - } - return { img, ok: false, err: 'exhausted retries' }; -} - -async function mapPool(items, limit, fn) { - const results = new Array(items.length); - let next = 0; - const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { - while (true) { - const i = next++; - if (i >= items.length) return; - results[i] = await fn(items[i]); - } - }); - await Promise.all(workers); - return results; -} - -test.describe('Template image manifests', () => { - test('every template image resolves via docker manifest inspect', async () => { - test.setTimeout(300_000); - const images = allTemplateImages(); - expect(images.length, 'no template images found').toBeGreaterThan(0); - - const results = await mapPool(images, CONCURRENCY, inspectWithRetry); - - const failures = results.filter((r) => !r.ok); - // If every failure is a Docker Hub rate-limit, don't fail the test - // (environmental) - just log. Real bad tags still surface. - const nonRateLimit = failures.filter((f) => !RATE_LIMIT_RE.test(f.err)); - if (failures.length > 0 && nonRateLimit.length === 0) { - console.warn( - `[template-images] skipping ${failures.length} image(s) due to Docker Hub rate limits`, - ); - return; - } - if (nonRateLimit.length > 0) { - const msg = nonRateLimit.map((f) => ` - ${f.img}: ${f.err.trim()}`).join('\n'); - throw new Error(`Unresolvable template images:\n${msg}`); - } - }); -}); diff --git a/e2e/tests/01-setup.spec.js b/e2e/tests/01-setup.spec.js index 67f665a0..520360d0 100644 --- a/e2e/tests/01-setup.spec.js +++ b/e2e/tests/01-setup.spec.js @@ -1,58 +1,34 @@ import { test, expect } from '@playwright/test'; import { getState, TEST_ADMIN } from '../helpers/auth.js'; +// Creates the admin the rest of the suite uses. Setup input validation is a +// unit concern (ui Login.test.js, internal/api/auth_test.go). test.describe('Initial Setup', () => { - test('redirects to login when no users exist', async ({ page }) => { + test('creates the first admin account through the setup form', async ({ page }) => { const state = getState(); - await page.goto(state.baseURL); - await expect(page).toHaveURL(/login/); - }); - - test('shows setup mode on login page', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible(); - }); - - test('rejects empty username', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await page.locator('#password').fill(TEST_ADMIN.password); - await page.locator('#confirmPassword').fill(TEST_ADMIN.password); - await page.getByRole('button', { name: 'Create Account' }).click(); - await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible(); - }); - - test('rejects short password', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await page.locator('#username').fill(TEST_ADMIN.username); - await page.locator('#password').fill('short'); - await page.locator('#confirmPassword').fill('short'); - await page.getByRole('button', { name: 'Create Account' }).click(); - await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible(); - }); - - test('rejects mismatched passwords', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await page.locator('#username').fill(TEST_ADMIN.username); - await page.locator('#password').fill(TEST_ADMIN.password); - await page.locator('#confirmPassword').fill('DifferentPass123!'); - await page.getByRole('button', { name: 'Create Account' }).click(); - await expect(page.getByText(/match/i)).toBeVisible(); - }); + const before = await page.request.get(`${state.baseURL}/api/setup/status`); + expect(await before.json()).toEqual({ needs_setup: true }); - test('creates admin account successfully', async ({ page }) => { - const state = getState(); await page.goto(`${state.baseURL}/#/login`); await page.locator('#displayName').fill(TEST_ADMIN.displayName); await page.locator('#email').fill(TEST_ADMIN.email); await page.locator('#username').fill(TEST_ADMIN.username); await page.locator('#password').fill(TEST_ADMIN.password); await page.locator('#confirmPassword').fill(TEST_ADMIN.password); + const setupRes = page.waitForResponse((r) => r.url().endsWith('/api/setup') && r.request().method() === 'POST'); await page.getByRole('button', { name: 'Create Account' }).click(); - // SPA uses hash routing; wait for sidebar/nav to confirm dashboard loaded - await page.waitForSelector('aside nav, nav', { timeout: 10_000 }); + expect((await setupRes).status()).toBe(201); + await page.waitForSelector('aside:not([data-testid="activity-sidebar"])', { timeout: 15_000 }); + + const me = await page.request.get(`${state.baseURL}/api/me`); + expect(me.status()).toBe(200); + const body = await me.json(); + expect(body.username).toBe(TEST_ADMIN.username); + expect(body.role).toBe('super_admin'); + expect(body.display_name).toBe(TEST_ADMIN.displayName); + expect(body.email).toBe(TEST_ADMIN.email); + + const after = await page.request.get(`${state.baseURL}/api/setup/status`); + expect(await after.json()).toEqual({ needs_setup: false }); }); }); diff --git a/e2e/tests/02-login.spec.js b/e2e/tests/02-login.spec.js index 5dbf3b36..57343ba4 100644 --- a/e2e/tests/02-login.spec.js +++ b/e2e/tests/02-login.spec.js @@ -1,48 +1,50 @@ import { test, expect } from '@playwright/test'; -import { getState, TEST_ADMIN, login, loginAsAdmin, logout } from '../helpers/auth.js'; +import { getState, TEST_ADMIN, loginViaUI, logout } from '../helpers/auth.js'; +// Other specs sign in through the API (helpers/auth.js login); the login +// form itself is only exercised here. test.describe('Authentication', () => { - test('login with correct credentials', async ({ page }) => { - await loginAsAdmin(page); - // Sidebar should be visible after login - await expect(page.locator('aside:not([data-testid="activity-sidebar"])')).toBeVisible(); + test('login form signs in as the admin', async ({ page }) => { + await loginViaUI(page); + const me = await page.request.get(`${getState().baseURL}/api/me`); + expect(me.status()).toBe(200); + const body = await me.json(); + expect(body.username).toBe(TEST_ADMIN.username); + expect(body.role).toBe('super_admin'); }); - test('login with wrong password shows error', async ({ page }) => { + test('wrong password is rejected with 401 and stays on login', async ({ page }) => { const state = getState(); await page.goto(`${state.baseURL}/#/login`); await page.locator('#username').fill(TEST_ADMIN.username); await page.locator('#password').fill('WrongPassword123!'); + const resP = page.waitForResponse('**/api/auth/login'); await page.getByRole('button', { name: 'Sign In' }).click(); - await expect(page.getByText(/invalid|incorrect|wrong/i)).toBeVisible({ timeout: 5_000 }); - }); - - test('login with nonexistent user shows error', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await page.locator('#username').fill('nonexistent'); - await page.locator('#password').fill('SomePassword123!'); - await page.getByRole('button', { name: 'Sign In' }).click(); - await expect(page.getByText(/invalid|incorrect|wrong/i)).toBeVisible({ timeout: 5_000 }); - }); - - test('logout and redirect to login', async ({ page }) => { - await loginAsAdmin(page); - await logout(page); + expect((await resP).status()).toBe(401); + await expect(page.getByText(/invalid credentials/i)).toBeVisible(); await expect(page).toHaveURL(/login/); + await expect(page.locator('aside:not([data-testid="activity-sidebar"])')).toHaveCount(0); }); test('accessing protected route without session redirects to login', async ({ page }) => { - const state = getState(); - await page.context().clearCookies(); - await page.goto(`${state.baseURL}/#/users`); + await page.goto(`${getState().baseURL}/#/users`); await expect(page).toHaveURL(/login/, { timeout: 5_000 }); }); - test('re-login after logout works', async ({ page }) => { - await loginAsAdmin(page); + test('logout revokes the session server-side; logging in again works', async ({ page }) => { + const state = getState(); + await loginViaUI(page); + const old = (await page.context().cookies()).find((c) => c.name === 'session'); + expect(old, 'session cookie after login').toBeTruthy(); + await logout(page); - await loginAsAdmin(page); - await expect(page.locator('aside:not([data-testid="activity-sidebar"])')).toBeVisible(); + await expect(page).toHaveURL(/login/); + + // The old cookie must be dead even if a client kept it. + const stale = await fetch(`${state.baseURL}/api/me`, { headers: { Cookie: `session=${old.value}` } }); + expect(stale.status).toBe(401); + + await loginViaUI(page); + expect((await page.request.get(`${state.baseURL}/api/me`)).status()).toBe(200); }); }); diff --git a/e2e/tests/03-deploy.spec.js b/e2e/tests/03-deploy.spec.js index e4452331..4e58b340 100644 --- a/e2e/tests/03-deploy.spec.js +++ b/e2e/tests/03-deploy.spec.js @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import { readFileSync } from 'fs'; import { join } from 'path'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, TEST_ADMIN } from '../helpers/auth.js'; import { findServiceContainer, containerRunning, @@ -10,9 +10,8 @@ import { listAppContainers, dockerExec, } from '../helpers/docker.js'; -import { fetchViaProxy } from '../helpers/proxy.js'; -import { apiRequest, apiLogin } from '../helpers/api.js'; -import { TEST_ADMIN } from '../helpers/auth.js'; +import { fetchViaProxy, waitForProxyStatus } from '../helpers/proxy.js'; +import { apiRequest, apiLogin, waitForAppStatus } from '../helpers/api.js'; const FIXTURES = join(import.meta.dirname, '..', 'fixtures'); @@ -28,131 +27,100 @@ async function deployApp(page, appName, composeContent) { await dialog.getByRole('button', { name: /build it yourself/i }).click(); await dialog.getByPlaceholder('my-app').fill(appName); await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - await editor.fill(composeContent); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); + await dialog.locator('textarea').last().fill(composeContent); + await expect(dialog.getByText('Valid compose file', { exact: true })).toBeVisible({ timeout: 10_000 }); await dialog.getByRole('button', { name: 'Next' }).click(); await dialog.getByRole('button', { name: 'Deploy' }).click(); await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - const viewAppBtn = dialog.getByRole('button', { name: 'View App' }); - if (await viewAppBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await viewAppBtn.click(); - } + await dialog.getByRole('button', { name: 'View App' }).click(); + await expect(page).toHaveURL(new RegExp(`#/apps/${appName}$`)); + await expect(page.locator('h1')).toHaveText(appName); + await waitForAppStatus(appName, 'running', 15_000); } test.describe('Deploy Apps', () => { + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + }); + test.beforeEach(async ({ page }) => { await loginAsAdmin(page); }); test('deploy nginx app', async ({ page }) => { - const compose = readFixture('compose-nginx.yml'); - await deployApp(page, 'e2e-nginx', compose); + await deployApp(page, 'e2e-nginx', readFixture('compose-nginx.yml')); }); test('deploy multi-service app', async ({ page }) => { - const compose = readFixture('compose-multi.yml'); - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await deployApp(page, 'e2e-multi', compose); + await deployApp(page, 'e2e-multi', readFixture('compose-multi.yml')); }); test('deploy postgres app', async ({ page }) => { - const compose = readFixture('compose-postgres.yml'); - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await deployApp(page, 'e2e-postgres', compose); + await deployApp(page, 'e2e-postgres', readFixture('compose-postgres.yml')); }); - test('reject invalid YAML', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); + test('invalid YAML fails server validation and blocks Next', async ({ page }) => { + const bad = 'this is not: valid: yaml: ['; await page.getByRole('button', { name: 'Deploy App' }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: /build it yourself/i }).click(); await dialog.getByPlaceholder('my-app').fill('bad-app'); await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - await editor.fill('this is not: valid: yaml: ['); - await expect(dialog.getByText(/failed|error|invalid/i).first()).toBeVisible({ timeout: 10_000 }); + const validated = page.waitForResponse((r) => + r.url().endsWith('/api/apps/validate-compose') && + r.request().postDataJSON()?.compose === Buffer.from(bad).toString('base64')); + await dialog.locator('textarea').last().fill(bad); + const res = await validated; + expect(res.status()).toBe(200); + expect((await res.json()).valid).toBe(false); + await expect(dialog.getByText(/^Invalid compose file:/)).toBeVisible(); + await expect(dialog.getByText('Valid compose file', { exact: true })).toHaveCount(0); + await expect(dialog.getByRole('button', { name: 'Next' })).toBeDisabled(); }); - test('deploying same template twice auto-suggests name-2', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - - // First deploy: pick nginx-static template, name it collide-tpl. - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - let dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /browse templates/i }).click(); - await dialog.getByRole('button', { name: /use template nginx static site/i }).click(); - // On vars step: default access mode is quick-test with prefilled host. Apply. - await dialog.getByRole('button', { name: /apply/i }).click(); - // Step 1: set app name. - const nameInput = dialog.getByPlaceholder('my-app'); - await expect(nameInput).toBeVisible(); - await nameInput.fill('collide-tpl'); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - // Close wizard via View App (matches the deployApp helper). - const viewApp = dialog.getByRole('button', { name: 'View App' }); - if (await viewApp.isVisible({ timeout: 3_000 }).catch(() => false)) { - await viewApp.click(); - } else { - await page.keyboard.press('Escape'); - } - await expect(dialog).toBeHidden({ timeout: 10_000 }); - await page.goto(`${state.baseURL}/#/`); - - // Second deploy: same template, same name -> expect name-taken modal. - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /browse templates/i }).click(); - await dialog.getByRole('button', { name: /use template nginx static site/i }).click(); - await dialog.getByRole('button', { name: /apply/i }).click(); - const nameInput2 = dialog.getByPlaceholder('my-app'); - await nameInput2.fill('collide-tpl'); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - - // Expect the name-taken modal with pre-filled suggestion "collide-tpl-2". - const modal = page.getByTestId('name-taken-modal'); - await expect(modal).toBeVisible({ timeout: 15_000 }); - const suggestionInput = page.getByTestId('name-taken-input'); - await expect(suggestionInput).toHaveValue('collide-tpl-2'); - await modal.getByRole('button', { name: 'Deploy' }).click(); - - await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - - // Both apps should exist. - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - const list = await apiRequest('GET', '/api/apps'); - const names = (list.data || []).map((a) => a.Name || a.name); - expect(names).toContain('collide-tpl'); - expect(names).toContain('collide-tpl-2'); - - // Clean up: leaving these apps behind pollutes the shared proxy with - // local-TLS endpoints, which breaks later plain-HTTP tests. - for (const slug of ['collide-tpl', 'collide-tpl-2']) { - await apiRequest('DELETE', `/api/apps/${slug}`); + test('template deploy reusing an existing name suggests name-2 and keeps the original', async ({ page }) => { + const before = await apiRequest('GET', '/api/apps/e2e-nginx'); + expect(before.status).toBe(200); + const beforeHash = before.data.ComposeHash; + expect(beforeHash).toBeTruthy(); + + try { + await page.getByRole('button', { name: 'Deploy App' }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: /browse templates/i }).click(); + await dialog.getByRole('button', { name: /use template nginx static site/i }).click(); + await dialog.getByRole('button', { name: /^custom domain$/i }).click(); + await dialog.locator('#tpl-var-domain').fill('tpl-e2e.localhost'); + await dialog.getByRole('button', { name: /^apply/i }).click(); + await dialog.getByPlaceholder('my-app').fill('e2e-nginx'); + await expect(dialog.getByText('Valid compose file', { exact: true })).toBeVisible({ timeout: 10_000 }); + await dialog.getByRole('button', { name: 'Next' }).click(); + await dialog.getByRole('button', { name: 'Deploy' }).click(); + + const modal = page.getByTestId('name-taken-modal'); + await expect(modal).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('name-taken-input')).toHaveValue('e2e-nginx-2'); + await modal.getByRole('button', { name: 'Deploy' }).click(); + await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); + + // The template's domain variable reaches the deployed app. + const copy = await waitForAppStatus('e2e-nginx-2', 'running', 15_000); + expect(copy.Domain).toBe('tpl-e2e.localhost'); + const after = await apiRequest('GET', '/api/apps/e2e-nginx'); + expect(after.data.ComposeHash).toBe(beforeHash); + } finally { + // Leftover template endpoints break later plain-HTTP specs. + await apiRequest('DELETE', '/api/apps/e2e-nginx-2'); } }); test('manual deploy with existing name shows inline error and does not overwrite', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - - // Capture existing compose hash for e2e-nginx before the attempt. - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); const before = await apiRequest('GET', '/api/apps/e2e-nginx'); - expect(before.ok).toBe(true); - const beforeHash = before.data && (before.data.ComposeHash || before.data.compose_hash); + expect(before.status).toBe(200); + const beforeHash = before.data.ComposeHash; + expect(beforeHash).toBeTruthy(); // Attempt a manual deploy reusing e2e-nginx name with a different compose. await page.getByRole('button', { name: 'Deploy App' }).first().click(); @@ -161,62 +129,41 @@ test.describe('Deploy Apps', () => { await dialog.getByRole('button', { name: /build it yourself/i }).click(); await dialog.getByPlaceholder('my-app').fill('e2e-nginx'); await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); // A valid but different compose (distinct image tag) so hash would change if it overwrote. - await editor.fill( + await dialog.locator('textarea').last().fill( "services:\n web:\n image: nginx:1.25-alpine\n ports:\n - '8080:80'\n" ); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('Valid compose file', { exact: true })).toBeVisible({ timeout: 10_000 }); await dialog.getByRole('button', { name: 'Next' }).click(); await dialog.getByRole('button', { name: 'Deploy' }).click(); // Expect inline name error on step 1 (no overwrite path in manual source). - await expect(dialog.getByText(/already exists/i).first()).toBeVisible({ timeout: 10_000 }); - // No template suggestion modal should appear for manual source. + await expect(dialog.getByText(/already exists/i)).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId('name-taken-modal')).toHaveCount(0); - // Verify the original e2e-nginx compose hash is unchanged. const after = await apiRequest('GET', '/api/apps/e2e-nginx'); - const afterHash = after.data && (after.data.ComposeHash || after.data.compose_hash); - expect(afterHash).toBe(beforeHash); + expect(after.data.ComposeHash).toBe(beforeHash); }); }); -test.describe.configure({ mode: 'serial' }); test.describe('Deploy - Functional', () => { test('nginx container is running with correct image and labels', async () => { const name = findServiceContainer('e2e-nginx', 'web'); expect(name, 'expected to find e2e-nginx web container').toBeTruthy(); expect(containerRunning(name)).toBe(true); - const img = containerImage(name); // Tolerate the GHCR image mirror (E2E_USE_MIRROR=1 rewrites image - // refs server-side), so match on the trailing repo:tag instead of - // anchoring at the start. - expect(img).toMatch(/(^|\/)nginx:/); + // refs server-side), so match on the trailing repo:tag. + expect(containerImage(name)).toMatch(/(^|\/)nginx:alpine$/); const info = dockerInspect(name); expect(info.Config.Labels['com.docker.compose.project']).toBe('simpledeploy-e2e-nginx'); expect(info.Config.Labels['com.docker.compose.service']).toBe('web'); }); test('proxy routes HTTP traffic for nginx-test.local', async () => { - // Caddy reload after deploy is async; poll until the route is live. - const deadline = Date.now() + 30_000; - let res, body = ''; - while (Date.now() < deadline) { - res = await fetchViaProxy('nginx-test.local', '/'); - if (res.status === 200) { - body = await res.text(); - if (body.includes('Welcome to nginx')) break; - } - await new Promise((r) => setTimeout(r, 500)); - } - expect(res.status).toBe(200); - expect(body).toContain('Welcome to nginx'); - - const missing = await fetchViaProxy('nonexistent.local', '/'); - const missingBody = await missing.text(); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); // Caddy returns 200 with empty body when no route matches; ensure no nginx content. - expect(missingBody).not.toContain('Welcome to nginx'); + const missing = await fetchViaProxy('nonexistent.local', '/'); + expect(await missing.text()).not.toContain('Welcome to nginx'); }); test('multi-service deploy runs both services and routes traffic', async () => { @@ -225,14 +172,10 @@ test.describe('Deploy - Functional', () => { for (const c of containers) { expect(containerRunning(c)).toBe(true); } - const res = await fetchViaProxy('multi-test.local', '/'); - expect(res.status).toBe(200); - const body = await res.text(); - expect(body).toContain('Welcome to nginx'); + await waitForProxyStatus('multi-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); - const redis = containers.find((n) => n.includes('cache')) || findServiceContainer('e2e-multi', 'cache'); + const redis = findServiceContainer('e2e-multi', 'cache'); expect(redis, 'expected to find redis/cache container').toBeTruthy(); - const out = dockerExec(redis, 'redis-cli PING').trim(); - expect(out).toBe('PONG'); + expect(dockerExec(redis, 'redis-cli PING').trim()).toBe('PONG'); }); }); diff --git a/e2e/tests/03b-deploy-templates.spec.js b/e2e/tests/03b-deploy-templates.spec.js deleted file mode 100644 index 114d8e64..00000000 --- a/e2e/tests/03b-deploy-templates.spec.js +++ /dev/null @@ -1,187 +0,0 @@ -// E2E: DeployWizard template flow. -// Exercises step 0 (Template grid), variable rendering, auto-generated secrets, -// Apply -> step 1 pre-fill, and full end-to-end deploy through the template path. -// Also covers the "Build it yourself" (blank compose) regression path. -// -// Runs AFTER 03-deploy.spec.js and BEFORE 04-dashboard.spec.js so the dashboard -// tests still see e2e-nginx / e2e-multi / e2e-postgres. The template-deployed -// apps (e2e-nginx-tpl, e2e-node-tpl, e2e-blank-tpl) are cleaned up via API -// after each test to avoid polluting later specs. - -import { test, expect } from '@playwright/test'; -import { loginAsAdmin, TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest } from '../helpers/api.js'; - -test.describe.configure({ mode: 'serial' }); - -async function cleanupApp(slug) { - try { await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); } catch {} - try { await apiRequest('DELETE', `/api/apps/${slug}`); } catch {} -} - -test.describe('Deploy via Templates', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('deploy via Nginx Static template', async ({ page }) => { - const slug = 'e2e-nginx-tpl'; - - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - // Step 0: click the Nginx Static card (button aria-label "Use template Nginx Static Site"). - await dialog.getByRole('button', { name: /^browse templates$/i }).click(); - await dialog.getByRole('button', { name: /use template nginx static/i }).click(); - - // Vars form: switch to Custom domain mode (default is Quick test via sslip.io). - await dialog.getByRole('button', { name: /^custom domain$/i }).click(); - const domainInput = dialog.locator('#tpl-var-domain'); - await expect(domainInput).toBeVisible(); - await domainInput.fill('e2e-nginx-tpl.localhost'); - - // Apply -> step 1. - await dialog.getByRole('button', { name: /^apply/i }).click(); - - // App name should be pre-filled by suggestName(). - const nameInput = dialog.getByPlaceholder('my-app'); - await expect(nameInput).toBeVisible(); - const suggested = await nameInput.inputValue(); - expect(suggested.length).toBeGreaterThan(0); - // Override to a deterministic slug so we can clean up reliably. - await nameInput.fill(slug); - - // Wait for compose validation to settle. - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - // Switch to YAML tab and verify the rendered compose contains the template's - // TLS + domain labels. - await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - const yamlText = await editor.inputValue(); - expect(yamlText).toContain('tls: letsencrypt'); - expect(yamlText).toContain('domain: e2e-nginx-tpl.localhost'); - - // Re-validate (YAML edit may have rescheduled validation). - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - // Next -> Deploy. - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - - await cleanupApp(slug); - }); - - test('deploy via Node API + Postgres template propagates backup label', async ({ page }) => { - // Deploy pulls node:20-alpine + postgres:16-alpine; on slow networks - // this can exceed 5 min end-to-end, so we give it a generous budget. - // test.setTimeout extends the default (360s) for this test only. - test.setTimeout(1_200_000); - const slug = 'e2e-node-tpl'; - - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - await dialog.getByRole('button', { name: /^browse templates$/i }).click(); - await dialog.getByRole('button', { name: /use template node api \+ postgres/i }).click(); - - await dialog.getByRole('button', { name: /^custom domain$/i }).click(); - const domainInput = dialog.locator('#tpl-var-domain'); - await expect(domainInput).toBeVisible(); - await domainInput.fill('e2e-node-tpl.localhost'); - - // Expand advanced accordion and confirm db_password auto-generated. - await dialog.getByText(/advanced \/ secrets/i).click(); - const pwInput = dialog.locator('#tpl-var-db_password'); - await expect(pwInput).toBeVisible(); - const originalPw = await pwInput.inputValue(); - expect(originalPw.length).toBeGreaterThan(0); - - // Regenerate and confirm the value changes. - await dialog.getByRole('button', { name: /^regenerate$/i }).click(); - const regenPw = await pwInput.inputValue(); - expect(regenPw.length).toBeGreaterThan(0); - expect(regenPw).not.toBe(originalPw); - - // Apply -> step 1. - await dialog.getByRole('button', { name: /^apply/i }).click(); - - const nameInput = dialog.getByPlaceholder('my-app'); - await expect(nameInput).toBeVisible(); - await nameInput.fill(slug); - - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - // Verify postgres backup label made it into the rendered compose. - await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - const yamlText = await editor.inputValue(); - expect(yamlText).toContain('simpledeploy.backup.strategy: postgres'); - - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - // Next -> Deploy. Node API + Postgres template pulls large images; - // allow up to 480s under the 600s test-level timeout. - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - // The point of this test is backup-label propagation, not container - // health. Accept any terminal state: the API service has no server.js - // in its empty volume by design (Unstable), and on slow CI runners - // the pull may be slow enough to land in Failed. Bumped timeout to - // cover slow Docker Hub pulls of node:20-alpine + postgres:16-alpine. - // Use a testid + data-deploy-status attr so we're resilient to whitespace - // and icon siblings inside the badge span (regex getByText was silently - // not matching "Unstable" despite the text being rendered). - const statusBadge = dialog.getByTestId('deploy-status'); - await expect(statusBadge).toHaveAttribute('data-deploy-status', /^(success|unstable|failed)$/, { timeout: 1_080_000 }); - - // Navigate to the app's Backups tab and confirm it renders. - await dialog.getByRole('button', { name: 'View App' }).click(); - await expect(page).toHaveURL(new RegExp(`/#/apps/${slug}`)); - // The tab labels render via capitalize; the DOM text is lowercase "backups". - await page.getByRole('button', { name: 'backups', exact: true }).click(); - // Hash routing: some deployments add ?tab=backups; just confirm tab content mounted. - // BackupsTab shows at minimum heading or controls; we assert no console-error surface - // by waiting for either a known control or an empty-state indicator. - await expect(page.locator('main')).toBeVisible(); - - await cleanupApp(slug); - }); - - test('start with a blank compose file still works', async ({ page }) => { - const slug = 'e2e-blank-tpl'; - - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - await dialog.getByRole('button', { name: /build it yourself/i }).click(); - - await dialog.getByPlaceholder('my-app').fill(slug); - - await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - const blankCompose = [ - 'services:', - ' web:', - ' image: nginx:alpine', - ' labels:', - ' simpledeploy.endpoints.0.domain: e2e-blank-tpl.localhost', - " simpledeploy.endpoints.0.port: '80'", - " simpledeploy.endpoints.0.tls: 'off'", - '', - ].join('\n'); - await editor.fill(blankCompose); - - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - - await cleanupApp(slug); - }); -}); diff --git a/e2e/tests/03c-community-recipes.spec.js b/e2e/tests/03c-community-recipes.spec.js index c69854d9..bf9b8723 100644 --- a/e2e/tests/03c-community-recipes.spec.js +++ b/e2e/tests/03c-community-recipes.spec.js @@ -1,51 +1,65 @@ // E2E: Community recipes browse + import flow. // -// Hits the live community catalog at vazra.github.io/simpledeploy-recipes. -// The catalog must contain at least the seed recipe `nginx-static` for this -// spec to pass. Catalog source: https://github.com/vazra/simpledeploy-recipes -// -// This spec only imports a recipe into the wizard editor; it does not deploy -// (no DNS/domain dependency, fast). +// Runs against an isolated server whose recipes_index_url points at a local +// fixture catalog (helpers/recipes.js), so the spec never depends on the +// live catalog. It imports a recipe into the wizard editor; it does not deploy. import { test, expect } from '@playwright/test'; -import { loginAsAdmin } from '../helpers/auth.js'; +import { startServer, stopServer, getBinaryPath } from '../helpers/server.js'; +import { apiRequestAt } from '../helpers/api.js'; +import { TEST_ADMIN } from '../helpers/auth.js'; +import { startRecipesCatalog, FIXTURE_RECIPE } from '../helpers/recipes.js'; test.describe('community recipes', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); + let catalog = null; + let server = null; + + test.beforeAll(async () => { + catalog = await startRecipesCatalog(); + server = await startServer(getBinaryPath(), { recipesIndexURL: catalog.indexURL }); + const setup = await apiRequestAt(server.baseURL, 'POST', '/api/setup', { + username: TEST_ADMIN.username, + password: TEST_ADMIN.password, + display_name: TEST_ADMIN.displayName, + email: TEST_ADMIN.email, + }); + expect(setup.status).toBe(201); }); - test('browse, view detail, import a recipe into deploy wizard', async ({ page }) => { - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog').first(); - await expect(dialog).toBeVisible(); + test.afterAll(async () => { + stopServer(server); + if (catalog) await catalog.stop(); + }); - // Open the templates grid first (step 0 has Browse Templates / Build it yourself). - await dialog.getByRole('button', { name: /^browse templates$/i }).click(); + test('browse, view detail, import a recipe into deploy wizard', async ({ page }) => { + const login = await page.request.post(`${server.baseURL}/api/auth/login`, { + data: { username: TEST_ADMIN.username, password: TEST_ADMIN.password }, + }); + expect(login.status()).toBe(200); + await page.goto(`${server.baseURL}/#/`); - // Open the community recipes browser from the template picker. - await dialog.getByRole('button', { name: /^browse community recipes$/i }).click(); + await page.locator('main').getByRole('button', { name: 'Deploy App' }).first().click(); + const wizard = page.getByRole('dialog'); + await expect(wizard).toBeVisible(); + await wizard.getByRole('button', { name: /^browse templates$/i }).click(); + await wizard.getByRole('button', { name: /^browse community recipes$/i }).click(); - // The browser opens as its own dialog. Scope subsequent queries to it. const browser = page.getByRole('dialog', { name: /browse community recipes/i }); - await expect(browser).toBeVisible({ timeout: 15_000 }); - - // Live catalog must contain the nginx-static seed recipe. - const card = browser.getByRole('button', { name: /nginx static site/i }); - await expect(card).toBeVisible({ timeout: 15_000 }); - await card.click(); + await expect(browser).toBeVisible(); + await browser.getByRole('button', { name: new RegExp(FIXTURE_RECIPE.name) }).click(); - // Detail view shows README + Use Recipe button. - await expect(browser.getByRole('button', { name: /^use recipe$/i })).toBeVisible({ timeout: 10_000 }); - - // Import the recipe. + // README comes from the catalog through the server's file proxy. + await expect(browser.locator('pre')).toContainText('Fixture README marker e2e-readme-7f3a.'); await browser.getByRole('button', { name: /^use recipe$/i }).click(); + await expect(browser).toBeHidden(); + + await expect(wizard.locator('textarea').last()).toHaveValue(FIXTURE_RECIPE.compose); + await expect(wizard.getByPlaceholder('my-app')).toHaveValue(FIXTURE_RECIPE.id); + await expect(wizard.getByText('Valid compose file', { exact: true })).toBeVisible({ timeout: 10_000 }); - // Wizard advances to step 1 with YAML editor in 'yaml' mode. - // The compose textarea should contain the recipe body. - const editor = dialog.locator('textarea').last(); - await expect(editor).toBeVisible({ timeout: 10_000 }); - const yamlText = await editor.inputValue(); - expect(yamlText.toLowerCase()).toMatch(/image:\s*nginx/); + // Using a recipe records a compose pull for the popularity report. + const pop = await page.request.get(`${server.baseURL}/api/recipes/community/popularity`); + expect(pop.status()).toBe(200); + expect((await pop.json()).recipes).toEqual([{ recipe_id: FIXTURE_RECIPE.id, count: 1 }]); }); }); diff --git a/e2e/tests/04-dashboard.spec.js b/e2e/tests/04-dashboard.spec.js index 81122f19..876b8edd 100644 --- a/e2e/tests/04-dashboard.spec.js +++ b/e2e/tests/04-dashboard.spec.js @@ -1,38 +1,45 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; + +const APPS = ['e2e-nginx', 'e2e-multi', 'e2e-postgres']; test.describe('Dashboard', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/`); + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('shows all 3 deployed app cards', async ({ page }) => { - await expect(page.getByRole('heading', { name: 'e2e-nginx', exact: true })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'e2e-multi', exact: true })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'e2e-postgres', exact: true })).toBeVisible(); - }); + test('shows app status, host metrics, search and card navigation', async ({ page }) => { + // Host metrics flow collector -> /api/metrics/system -> stat cards. Wait + // for the first system point so the page load below must render it. + await expect.poll(async () => { + const res = await apiRequest('GET', '/api/metrics/system?range=1h'); + return res.data?.containers?.['']?.points?.length || 0; + }, { timeout: 30_000 }).toBeGreaterThan(0); - test('app cards show running status', async ({ page }) => { - // Filter bar shows "Running N" count; app cards show "running" badges - await expect(page.getByText(/Running \d/)).toBeVisible(); - }); + await loginAsAdmin(page); + const main = page.locator('main'); - test('system metrics section renders', async ({ page }) => { - await expect(page.getByText(/cpu/i).first()).toBeVisible(); - await expect(page.getByText(/memory/i).first()).toBeVisible(); - }); + for (const slug of APPS) { + const card = main.locator(`a[href="#/apps/${slug}"]`); + await expect(card.getByRole('heading', { name: slug, exact: true })).toBeVisible(); + await expect(card.getByText('running', { exact: true })).toBeVisible(); + } + const apps = (await apiRequest('GET', '/api/apps')).data; + const running = apps.filter((a) => a.Status === 'running').length; + await expect(main.getByRole('button', { name: /^Running/ }).locator('span').last()).toHaveText(String(running)); - test('search filters apps', async ({ page }) => { - await page.getByPlaceholder(/search/i).fill('nginx'); - await expect(page.getByRole('heading', { name: 'e2e-nginx', exact: true })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'e2e-multi', exact: true })).not.toBeVisible(); - await expect(page.getByRole('heading', { name: 'e2e-postgres', exact: true })).not.toBeVisible(); - }); + // Memory stat card sub-text reads " / "; both stay "0" without data. + const memCard = main.getByText('Memory', { exact: true }).locator('xpath=../..'); + await expect(memCard.getByText(/^\d+\.\d [KMGT]?B \/ \d+\.\d [KMGT]?B$/)).toBeVisible(); + + await main.getByPlaceholder(/search/i).fill('nginx'); + await expect(main.getByRole('heading', { name: 'e2e-nginx', exact: true })).toBeVisible(); + await expect(main.getByRole('heading', { name: 'e2e-multi', exact: true })).toHaveCount(0); + await expect(main.getByRole('heading', { name: 'e2e-postgres', exact: true })).toHaveCount(0); - test('clicking app card navigates to app detail', async ({ page }) => { - await page.getByRole('heading', { name: 'e2e-nginx', exact: true }).click(); - await expect(page).toHaveURL(/apps\/e2e-nginx/); + await main.getByRole('heading', { name: 'e2e-nginx', exact: true }).click(); + await expect(page).toHaveURL(/#\/apps\/e2e-nginx$/); + await expect(page.locator('h1')).toHaveText('e2e-nginx'); }); }); diff --git a/e2e/tests/05-app-overview.spec.js b/e2e/tests/05-app-overview.spec.js deleted file mode 100644 index 684f658c..00000000 --- a/e2e/tests/05-app-overview.spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; - -test.describe('App Overview', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - }); - - test('shows app name and running status', async ({ page }) => { - await expect(page.locator('h1').getByText('e2e-nginx')).toBeVisible(); - await expect(page.getByText(/running/i).first()).toBeVisible(); - }); - - test('shows services list', async ({ page }) => { - await expect(page.locator('main').getByText('web').first()).toBeVisible(); - }); - - test('shows action buttons', async ({ page }) => { - await expect(page.getByRole('button', { name: /restart/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /stop/i })).toBeVisible(); - }); - - test('multi-service app shows services', async ({ page }) => { - const state = getState(); - // beforeEach already mounted AppDetail for e2e-nginx. svelte-spa-router - // reuses the component instance on hash changes, and AppDetail only - // loads data in onMount (no reactive effect on slug). Force a full page - // load so the component remounts with the new slug. - await page.goto(`${state.baseURL}/#/apps/e2e-multi`, { waitUntil: 'load' }); - await page.reload(); - await expect(page.locator('h1').getByText('e2e-multi')).toBeVisible({ timeout: 15_000 }); - await expect(page.locator('main').getByText('Services').first()).toBeVisible({ timeout: 10_000 }); - }); - - test('tab navigation works', async ({ page }) => { - const tabs = ['logs', 'metrics', 'backups', 'settings']; - for (const tab of tabs) { - const btn = page.getByRole('button', { name: new RegExp(tab, 'i') }); - await btn.click(); - // Verify tab became active (wait for content area to update) - await expect(btn).toBeVisible(); - } - await page.getByRole('button', { name: /overview/i }).click(); - }); -}); diff --git a/e2e/tests/06-app-actions.spec.js b/e2e/tests/06-app-actions.spec.js index bc47eb94..0c65634e 100644 --- a/e2e/tests/06-app-actions.spec.js +++ b/e2e/tests/06-app-actions.spec.js @@ -1,229 +1,132 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiRequest, apiLogin, waitForAppStatus } from '../helpers/api.js'; import { findServiceContainer, - containerRunning, dockerInspect, waitForContainerState, countServiceReplicas, } from '../helpers/docker.js'; -import { fetchViaProxy } from '../helpers/proxy.js'; - +import { waitForProxyStatus } from '../helpers/proxy.js'; + +async function eventCount(slug, action) { + const res = await apiRequest('GET', `/api/apps/${slug}/events`); + expect(res.status).toBe(200); + return res.data.filter((e) => e.action === action).length; +} + +function actionResponse(page, slug, action) { + return page.waitForResponse((r) => + r.url().endsWith(`/api/apps/${slug}/${action}`) && r.request().method() === 'POST'); +} + +// ActionModal streams the async action over WS and shows the done action. +async function expectActionComplete(page, action, timeout) { + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('Complete', { exact: true })).toBeVisible({ timeout }); + await expect(dialog.getByText(action, { exact: true })).toBeVisible(); + await dialog.locator('button:has-text("Close"):not([aria-label])').click(); + await expect(dialog).toBeHidden(); +} + +// stop leaves e2e-nginx stopped for start; the rest expect it running. test.describe('App Actions', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('stop app', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /stop/i }).click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - const confirmBtn = dialog.getByRole('button', { name: /stop|confirm|yes/i }); - if (await confirmBtn.isVisible({ timeout: 1_000 }).catch(() => false)) { - await confirmBtn.click(); - } - } - await expect(page.getByText(/stopped/i).first()).toBeVisible({ timeout: 30_000 }); - }); - - test('start app', async ({ page }) => { - const state = getState(); - // Ensure stopped via API to avoid cross-test ordering flakiness. - await apiLogin('e2eadmin', 'E2eTestPass123!'); - await apiRequest('POST', '/api/apps/e2e-nginx/stop'); - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const res = await apiRequest('GET', '/api/apps/e2e-nginx'); - if (res.ok && res.data?.status === 'stopped') break; - await new Promise(r => setTimeout(r, 1_000)); - } - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await expect(page.getByRole('button', { name: 'Start' })).toBeVisible({ timeout: 30_000 }); - await page.getByRole('button', { name: 'Start' }).click(); - await expect(page.getByText(/running/i).first()).toBeVisible({ timeout: 30_000 }); - }); - - test('restart app', async ({ page }) => { - const state = getState(); - // Use the API to ensure app is running before testing restart - const { apiRequest, apiLogin } = await import('../helpers/api.js'); - await apiLogin('e2eadmin', 'E2eTestPass123!'); - await apiRequest('POST', '/api/apps/e2e-nginx/start'); - // Wait for Docker to start the container - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const res = await apiRequest('GET', '/api/apps/e2e-nginx'); - if (res.ok && res.data?.status === 'running') break; - await new Promise(r => setTimeout(r, 2_000)); - } - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await expect(page.getByRole('button', { name: /restart/i })).toBeVisible({ timeout: 15_000 }); - await page.getByRole('button', { name: /restart/i }).click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - const confirmBtn = dialog.getByRole('button', { name: /restart|confirm/i }); - if (await confirmBtn.isVisible({ timeout: 1_000 }).catch(() => false)) { - await confirmBtn.click(); - } - } - // Wait for action modal close button (inside the dialog, not the backdrop) - const actionDialog = page.getByRole('dialog'); - const closeBtn = actionDialog.locator('button:has-text("Close"):not([aria-label])'); - await expect(closeBtn).toBeVisible({ timeout: 60_000 }); - await closeBtn.click(); - await expect(page.getByText(/running/i).first()).toBeVisible({ timeout: 15_000 }); - }); - - test('pull and update', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /pull/i }).click(); - // Wait for action modal close button (inside dialog footer, not backdrop) - const actionDialog = page.getByRole('dialog'); - const closeBtn = actionDialog.locator('button:has-text("Close"):not([aria-label])'); - await expect(closeBtn).toBeVisible({ timeout: 120_000 }); - await closeBtn.click(); + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('scale service', async ({ page }) => { - const state = getState(); - // Make sure app is running so services render with scale controls. - await apiLogin('e2eadmin', 'E2eTestPass123!'); - await apiRequest('POST', '/api/apps/e2e-multi/start'); - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const res = await apiRequest('GET', '/api/apps/e2e-multi'); - if (res.ok && res.data?.status === 'running') break; - await new Promise(r => setTimeout(r, 1_000)); - } - await page.goto(`${state.baseURL}/#/apps/e2e-multi`); - // Scale uses inline +/- buttons per scalable service in OverviewTab. - const incBtn = page.getByRole('button', { name: 'Increase replicas' }).first(); - await expect(incBtn).toBeVisible({ timeout: 30_000 }); - await incBtn.click(); - await expect(page.getByText(/running/i).first()).toBeVisible({ timeout: 30_000 }); + // Later specs need e2e-nginx running even if start failed here. + test.afterAll(async () => { + const app = await apiRequest('GET', '/api/apps/e2e-nginx'); + if (app.data?.Status === 'stopped') await apiRequest('POST', '/api/apps/e2e-nginx/start'); }); -}); -test.describe.configure({ mode: 'serial' }); -test.describe('App Actions - Functional', () => { - test.beforeAll(async () => { - await apiLogin('e2eadmin', 'E2eTestPass123!'); + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); }); - test('stop actually stops container and proxy returns upstream error', async () => { - // Ensure running first. - await apiRequest('POST', '/api/apps/e2e-nginx/start'); + test('stop stops the container and the proxy reports a bad gateway', async ({ page }) => { const name = findServiceContainer('e2e-nginx', 'web'); expect(name).toBeTruthy(); - await waitForContainerState(name, true, 30_000); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + const res = actionResponse(page, 'e2e-nginx', 'stop'); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + expect((await res).status()).toBe(200); + await expect(page.getByRole('button', { name: 'Start', exact: true })).toBeVisible(); - const res = await apiRequest('POST', '/api/apps/e2e-nginx/stop'); - expect(res.ok).toBe(true); await waitForContainerState(name, false, 30_000); - expect(containerRunning(name)).toBe(false); - - // With container down, proxy should fail to reach upstream. - const r = await fetchViaProxy('nginx-test.local', '/'); - expect([502, 503, 504, 500, 0, 404].includes(r.status)).toBe(true); - expect(r.status).not.toBe(200); + await waitForAppStatus('e2e-nginx', 'stopped', 5_000); + await waitForProxyStatus('nginx-test.local', 502); }); - test('start actually starts container and proxy serves 200', async () => { - const res = await apiRequest('POST', '/api/apps/e2e-nginx/start'); - expect(res.ok).toBe(true); + test('start brings the container back and the proxy serves it', async ({ page }) => { const name = findServiceContainer('e2e-nginx', 'web'); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + const res = actionResponse(page, 'e2e-nginx', 'start'); + await page.getByRole('button', { name: 'Start', exact: true }).click(); + expect((await res).status()).toBe(200); + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toBeVisible(); + await waitForContainerState(name, true, 30_000); - expect(containerRunning(name)).toBe(true); - - // Give proxy a moment to re-route. - const deadline = Date.now() + 30_000; - let last = 0; - while (Date.now() < deadline) { - const r = await fetchViaProxy('nginx-test.local', '/'); - last = r.status; - if (r.status === 200) break; - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(last).toBe(200); + await waitForAppStatus('e2e-nginx', 'running', 5_000); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); }); - test('restart preserves container but bumps StartedAt', async () => { + test('restart restarts the container in place', async ({ page }) => { const name = findServiceContainer('e2e-nginx', 'web'); - expect(name).toBeTruthy(); - const beforeStart = dockerInspect(name).State.StartedAt; - const beforeMs = new Date(beforeStart).getTime(); - - const res = await apiRequest('POST', '/api/apps/e2e-nginx/restart'); - expect(res.ok).toBe(true); - - const deadline = Date.now() + 60_000; - let advanced = false; - while (Date.now() < deadline) { - try { - const after = dockerInspect(findServiceContainer('e2e-nginx', 'web')).State.StartedAt; - const afterMs = new Date(after).getTime(); - if (afterMs > beforeMs) { - advanced = true; - break; - } - } catch {} - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(advanced).toBe(true); + const before = dockerInspect(name); + const restarts = await eventCount('e2e-nginx', 'restart'); + + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'Restart', exact: true }).click(); + const confirm = page.getByRole('dialog').filter({ hasText: 'Restart App' }); + const res = actionResponse(page, 'e2e-nginx', 'restart'); + await confirm.getByRole('button', { name: 'Restart', exact: true }).click(); + expect((await res).status()).toBe(202); + await expect(confirm).toBeHidden(); + await expectActionComplete(page, 'restart', 60_000); + + const after = dockerInspect(name); + expect(after.Id).toBe(before.Id); + expect(new Date(after.State.StartedAt).getTime()).toBeGreaterThan(new Date(before.State.StartedAt).getTime()); + // The event row is written just after the WS done message. + await expect.poll(() => eventCount('e2e-nginx', 'restart')).toBe(restarts + 1); + await waitForAppStatus('e2e-nginx', 'running', 5_000); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); }); - test('scale to 2 creates 2nd replica then back to 1', async () => { - // Use the cache (redis) service since web has a fixed host-port mapping - // that would conflict when scaled to multiple replicas. - test.setTimeout(180_000); - const up = await apiRequest('POST', '/api/apps/e2e-multi/scale', { scales: { cache: 2 } }); - expect(up.ok, `scale up failed: ${JSON.stringify(up.data)}`).toBe(true); - - const deadline = Date.now() + 60_000; - while (Date.now() < deadline) { - if (countServiceReplicas('e2e-multi', 'cache') === 2) break; - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(countServiceReplicas('e2e-multi', 'cache')).toBe(2); - - const down = await apiRequest('POST', '/api/apps/e2e-multi/scale', { scales: { cache: 1 } }); - expect(down.ok).toBe(true); - - const deadline2 = Date.now() + 60_000; - while (Date.now() < deadline2) { - if (countServiceReplicas('e2e-multi', 'cache') === 1) break; - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(countServiceReplicas('e2e-multi', 'cache')).toBe(1); + test('pull & update re-pulls and keeps serving', async ({ page }) => { + const pulls = await eventCount('e2e-nginx', 'pull'); + + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + const res = actionResponse(page, 'e2e-nginx', 'pull'); + await page.getByRole('button', { name: 'Pull & Update' }).click(); + expect((await res).status()).toBe(202); + await expectActionComplete(page, 'pull', 120_000); + + await expect.poll(() => eventCount('e2e-nginx', 'pull')).toBe(pulls + 1); + await waitForAppStatus('e2e-nginx', 'running', 5_000); + await waitForContainerState(findServiceContainer('e2e-nginx', 'web'), true, 5_000); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); }); - test('pull keeps app running and serving traffic', async () => { - test.setTimeout(180_000); - const name = findServiceContainer('e2e-nginx', 'web'); - expect(name).toBeTruthy(); - const beforeImage = dockerInspect(name).Image; - - const res = await apiRequest('POST', '/api/apps/e2e-nginx/pull'); - expect([200, 202].includes(res.status)).toBe(true); - - // pull is async; give it time to complete. - await new Promise((r) => setTimeout(r, 20_000)); - // Wait for running status. - await waitForAppStatus('e2e-nginx', 'running', 60_000); - - const currentName = findServiceContainer('e2e-nginx', 'web'); - expect(currentName).toBeTruthy(); - expect(containerRunning(currentName)).toBe(true); - // Image digest exists (may or may not change depending on registry state). - const afterImage = dockerInspect(currentName).Image; - expect(afterImage).toBeTruthy(); - expect(typeof beforeImage).toBe('string'); - - const r = await fetchViaProxy('nginx-test.local', '/'); - expect(r.status).toBe(200); + test('scale cache to 2 replicas and back to 1', async ({ page }) => { + // web has a fixed host port, so only cache is scalable. + await page.goto(`${getState().baseURL}/#/apps/e2e-multi`); + const row = page.locator('main div.rounded-lg').filter({ has: page.locator('span[title="cache"]') }); + const replicas = row.locator('span[title="Current replicas"]'); + await expect(replicas).toHaveText('1'); + + let res = actionResponse(page, 'e2e-multi', 'scale'); + await row.getByRole('button', { name: 'Increase replicas' }).click(); + expect((await res).status()).toBe(200); + await expect(replicas).toHaveText('2'); + await expect.poll(() => countServiceReplicas('e2e-multi', 'cache'), { timeout: 30_000 }).toBe(2); + + res = actionResponse(page, 'e2e-multi', 'scale'); + await row.getByRole('button', { name: 'Decrease replicas' }).click(); + expect((await res).status()).toBe(200); + await expect(replicas).toHaveText('1'); + await expect.poll(() => countServiceReplicas('e2e-multi', 'cache'), { timeout: 30_000 }).toBe(1); }); }); diff --git a/e2e/tests/07-app-config.spec.js b/e2e/tests/07-app-config.spec.js index 90d554ff..42ed7fb2 100644 --- a/e2e/tests/07-app-config.spec.js +++ b/e2e/tests/07-app-config.spec.js @@ -1,21 +1,45 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, waitForDeployDone } from '../helpers/api.js'; +import { dockerCLI } from '../helpers/docker.js'; +import { waitForProxyStatus } from '../helpers/proxy.js'; test.describe('App Configuration', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('view compose file in config tab', async ({ page }) => { - await page.getByRole('button', { name: /settings/i }).click(); - await expect(page.getByText('nginx:alpine')).toBeVisible({ timeout: 5_000 }); - }); + // Leaves e2e-nginx on nginx:1.27-alpine with a second compose version, + // which 11-app-versions and 23-rollback-and-restore build on. + test('Save & Deploy from the YAML editor redeploys with the edited image', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'settings', exact: true }).click(); + const main = page.locator('main'); + await main.getByRole('button', { name: 'YAML', exact: true }).click(); + + const editor = main.locator('textarea').first(); + // With E2E_USE_MIRROR=1 the stored image carries the mirror prefix. + await expect(editor).toHaveValue(/image: (\S+\/)?nginx:alpine\n/); + const edited = (await editor.inputValue()).replace(/image: (\S+\/)?nginx:alpine/, 'image: $1nginx:1.27-alpine'); + await editor.fill(edited); + await main.getByRole('button', { name: 'Save & Deploy' }).click(); + + const review = page.getByRole('dialog').filter({ hasText: 'Review Changes' }); + await expect(review.getByText(/^- +image: (\S+\/)?nginx:alpine$/)).toBeVisible(); + await expect(review.getByText(/^\+ +image: (\S+\/)?nginx:1\.27-alpine$/)).toBeVisible(); + const deployRes = page.waitForResponse((r) => r.url().endsWith('/api/apps/deploy') && r.request().method() === 'POST'); + await review.getByRole('button', { name: 'Confirm & Deploy' }).click(); + expect((await deployRes).status()).toBe(202); + await expect(review).toBeHidden(); - test('environment variables section visible', async ({ page }) => { - await page.getByRole('button', { name: /settings/i }).click(); - await expect(page.getByText(/environment variables/i).first()).toBeVisible({ timeout: 5_000 }); - await expect(page.getByRole('button', { name: /add variable/i })).toBeVisible(); + // One docker ps call: the container is replaced mid-poll. + await expect.poll(() => dockerCLI([ + 'ps', '--format', '{{.Image}}', + '--filter', 'label=com.docker.compose.project=simpledeploy-e2e-nginx', + '--filter', 'label=com.docker.compose.service=web', + ]).trim(), { timeout: 90_000 }).toMatch(/^(\S+\/)?nginx:1\.27-alpine$/); + expect((await waitForDeployDone('e2e-nginx')).Status).toBe('running'); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); }); }); diff --git a/e2e/tests/08-app-endpoints.spec.js b/e2e/tests/08-app-endpoints.spec.js index f84705e9..e8f12119 100644 --- a/e2e/tests/08-app-endpoints.spec.js +++ b/e2e/tests/08-app-endpoints.spec.js @@ -1,116 +1,72 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest } from '../helpers/api.js'; -import { fetchViaProxy } from '../helpers/proxy.js'; - -test.describe('Endpoints & Access', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: 'Settings' }).click(); - }); - - test('shows current endpoints', async ({ page }) => { - await expect(page.getByText('nginx-test.local').first()).toBeVisible({ timeout: 10_000 }); - }); - - test('shows TLS mode', async ({ page }) => { - await expect(page.getByText(/No TLS|Auto TLS|Local CA|Custom TLS/i).first()).toBeVisible({ timeout: 5_000 }); - }); - - test('Advanced section has IP allowlist', async ({ page }) => { - // Verify the Advanced section and its contents exist in the DOM - // even if the collapsible toggle has rendering issues - const hasAdvanced = await page.getByRole('button', { name: 'Advanced' }).isVisible(); - expect(hasAdvanced).toBeTruthy(); - }); -}); +import { fetchViaProxy, waitForProxyStatus } from '../helpers/proxy.js'; const ORIGINAL_NGINX_ENDPOINTS = [ { domain: 'nginx-test.local', port: '80', tls: 'off', service: 'web' }, ]; -// waitForProxyStatus polls Caddy until the response matches wantStatus and, -// when bodyContains is provided, the response body also contains that -// substring. The body check is necessary because Caddy's fallthrough -// handler can return 200 with an empty body during the window between the -// endpoints PUT and the reconciler installing the new route; accepting that -// empty 200 would mask real routing failures. -async function waitForProxyStatus(host, wantStatus, timeoutMs = 15_000, bodyContains = null) { - const deadline = Date.now() + timeoutMs; - let last = 0; - let lastBody = ''; - while (Date.now() < deadline) { - try { - const r = await fetchViaProxy(host, '/'); - last = r.status; - if (r.status === wantStatus) { - if (!bodyContains) return r; - const body = await r.text(); - lastBody = body.slice(0, 60); - if (body.includes(bodyContains)) { - return { ...r, text: async () => body }; - } - } - } catch {} - await new Promise((r) => setTimeout(r, 500)); - } - const hint = bodyContains ? ` body~${JSON.stringify(bodyContains)}` : ''; - throw new Error(`host ${host} did not reach status ${wantStatus}${hint} within ${timeoutMs}ms (last=${last}, lastBody=${lastBody})`); -} - async function waitForProxyNotNginx(host, timeoutMs = 15_000) { const deadline = Date.now() + timeoutMs; let last = ''; while (Date.now() < deadline) { - try { - const r = await fetchViaProxy(host, '/'); - const body = await r.text(); - last = body.slice(0, 60); - if (r.status !== 200 || !body.includes('Welcome to nginx')) return { status: r.status, body }; - } catch { - return { status: 0, body: '' }; - } - await new Promise((r) => setTimeout(r, 500)); + const r = await fetchViaProxy(host, '/'); + const body = await r.text(); + last = body.slice(0, 60); + if (r.status !== 200 || !body.includes('Welcome to nginx')) return { status: r.status, body }; + await new Promise((res) => setTimeout(res, 300)); } throw new Error(`host ${host} still routed to nginx within ${timeoutMs}ms (last body=${last})`); } -test.describe.configure({ mode: 'serial' }); -test.describe('Endpoints - Functional', () => { +test.describe('Endpoints', () => { test.beforeAll(async () => { - await apiLogin('e2eadmin', 'E2eTestPass123!'); + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); + // Every test changes e2e-nginx endpoints; later specs need the original + // route back and serving. test.afterEach(async () => { - // Always restore nginx endpoints after each functional test. - await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', ORIGINAL_NGINX_ENDPOINTS); - try { - await waitForProxyStatus('nginx-test.local', 200, 20_000, 'Welcome to nginx'); - } catch {} + const res = await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', ORIGINAL_NGINX_ENDPOINTS); + expect(res.status).toBe(200); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 20_000, bodyContains: 'Welcome to nginx' }); }); - test('added domain becomes reachable via proxy', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', [ - ...ORIGINAL_NGINX_ENDPOINTS, - { domain: 'added-e2e.local', port: '80', tls: 'off', service: 'web' }, - ]); - expect(res.ok).toBe(true); + test('endpoint added in Settings routes through the proxy', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'settings', exact: true }).click(); - const r = await waitForProxyStatus('added-e2e.local', 200, 40_000, 'Welcome to nginx'); - const body = await r.text(); - expect(body).toContain('Welcome to nginx'); + const card = page.locator('main div.rounded-xl').filter({ has: page.getByRole('heading', { name: 'Endpoints', exact: true }) }); + const original = card.locator('div.group').filter({ hasText: 'nginx-test.local' }); + await expect(original).toContainText('web:80'); + await expect(original.getByText('No TLS', { exact: true })).toBeVisible(); + + await card.getByRole('button', { name: '+ Add' }).click(); + await card.getByPlaceholder('myapp.example.com').fill('added-e2e.local'); + await card.getByPlaceholder('3000').fill('80'); + await card.locator('select').filter({ has: page.locator('option[value="off"]') }).selectOption('off'); + const res = page.waitForResponse((r) => r.url().endsWith('/api/apps/e2e-nginx/endpoints') && r.request().method() === 'PUT'); + await card.getByRole('button', { name: 'Add Endpoint' }).click(); + expect((await res).status()).toBe(200); + + const added = card.locator('div.group').filter({ hasText: 'added-e2e.local' }); + await expect(added).toContainText('web:80'); + await expect(added.getByText('No TLS', { exact: true })).toBeVisible(); + await waitForProxyStatus('added-e2e.local', 200, { timeoutMs: 40_000, bodyContains: 'Welcome to nginx' }); + await waitForProxyStatus('nginx-test.local', 200, { bodyContains: 'Welcome to nginx' }); }); - test('removed domain returns non-200 via proxy', async () => { + test('removed domain stops routing to the app', async () => { const res = await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', [ { domain: 'only-other.local', port: '80', tls: 'off', service: 'web' }, ]); - expect(res.ok).toBe(true); + expect(res.status).toBe(200); const r = await waitForProxyNotNginx('nginx-test.local', 20_000); - expect(r.body || '').not.toContain('Welcome to nginx'); + expect(r.body).not.toContain('Welcome to nginx'); + await waitForProxyStatus('only-other.local', 200, { timeoutMs: 20_000, bodyContains: 'Welcome to nginx' }); }); test('multiple endpoints for same service both route to 200', async () => { @@ -118,11 +74,9 @@ test.describe('Endpoints - Functional', () => { ...ORIGINAL_NGINX_ENDPOINTS, { domain: 'alt-e2e.local', port: '80', tls: 'off', service: 'web' }, ]); - expect(res.ok).toBe(true); + expect(res.status).toBe(200); - const a = await waitForProxyStatus('nginx-test.local', 200, 20_000, 'Welcome to nginx'); - expect(a.status).toBe(200); - const b = await waitForProxyStatus('alt-e2e.local', 200, 20_000, 'Welcome to nginx'); - expect(b.status).toBe(200); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 20_000, bodyContains: 'Welcome to nginx' }); + await waitForProxyStatus('alt-e2e.local', 200, { timeoutMs: 20_000, bodyContains: 'Welcome to nginx' }); }); }); diff --git a/e2e/tests/09-app-logs.spec.js b/e2e/tests/09-app-logs.spec.js index f363d956..1345eb7d 100644 --- a/e2e/tests/09-app-logs.spec.js +++ b/e2e/tests/09-app-logs.spec.js @@ -1,38 +1,38 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; +import { fetchViaProxy } from '../helpers/proxy.js'; test.describe('App Logs', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /logs/i }).click(); + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('log viewer renders', async ({ page }) => { - const logContainer = page.locator('.font-mono').first(); - await expect(logContainer).toBeVisible({ timeout: 10_000 }); + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); }); - test('log controls visible', async ({ page }) => { - const followBtn = page.getByText(/following|paused/i).first(); - await expect(followBtn).toBeVisible({ timeout: 5_000 }); + test('log viewer streams a fresh nginx access log line', async ({ page }) => { + await page.getByRole('button', { name: 'logs', exact: true }).click(); + const marker = `/e2e-log-${Date.now()}`; + const r = await fetchViaProxy('nginx-test.local', marker); + expect(r.status).toBe(404); + // nginx access log -> docker logs -> logs WS -> viewer. + await expect(page.locator('main').getByText(new RegExp(`"GET ${marker} HTTP/1\\.1" 404`))).toBeVisible(); }); - test('clear button works', async ({ page }) => { - const clearBtn = page.getByRole('button', { name: /clear/i }); - if (await clearBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await clearBtn.click(); - } - }); + test('events tab lists the deploy events from the API', async ({ page }) => { + const res = await apiRequest('GET', '/api/apps/e2e-nginx/events'); + expect(res.status).toBe(200); + const actions = res.data.map((e) => e.action); + expect(actions).toContain('deploy'); + expect(actions.filter((a) => /_(failed|unstable)$/.test(a))).toEqual([]); - test('deploy logs available in events tab', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - const eventsBtn = page.getByRole('button', { name: /event|history/i }); - if (await eventsBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await eventsBtn.click(); - await expect(page.getByText(/deploy/i).first()).toBeVisible({ timeout: 5_000 }); + await page.getByRole('button', { name: 'events', exact: true }).click(); + const main = page.locator('main'); + for (const action of new Set(actions)) { + await expect(main.getByText(action, { exact: true })).toHaveCount(actions.filter((a) => a === action).length); } }); }); diff --git a/e2e/tests/10-app-metrics.spec.js b/e2e/tests/10-app-metrics.spec.js index 773938bc..c4505e02 100644 --- a/e2e/tests/10-app-metrics.spec.js +++ b/e2e/tests/10-app-metrics.spec.js @@ -1,89 +1,40 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest } from '../helpers/api.js'; import { fetchViaProxy } from '../helpers/proxy.js'; -import { sqliteQuery } from '../helpers/db.js'; +import { sqliteQuery, getAppId } from '../helpers/db.js'; test.describe('App Metrics', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /metrics/i }).click(); - }); - - test('metrics charts render', async ({ page }) => { - // Charts need time to fetch data and render via Chart.js - const canvases = page.locator('canvas'); - await expect(canvases.first()).toBeVisible({ timeout: 15_000 }); - }); - - test('time range buttons visible', async ({ page }) => { - await expect(page.getByRole('button', { name: '1h' })).toBeVisible(); - await expect(page.getByRole('button', { name: '24h' })).toBeVisible(); - }); - - test('switch time range', async ({ page }) => { - await page.getByRole('button', { name: '6h' }).click(); - await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15_000 }); - }); - - test('CPU and memory chart labels visible', async ({ page }) => { - await expect(page.getByText(/cpu/i).first()).toBeVisible(); - await expect(page.getByText(/memory/i).first()).toBeVisible(); - }); -}); - -test.describe.configure({ mode: 'serial' }); -test.describe('Metrics - Functional', () => { test.beforeAll(async () => { - await apiLogin('e2eadmin', 'E2eTestPass123!'); + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('metrics DB accumulates data after traffic', async () => { - test.setTimeout(180_000); + test('switching to 6h loads container metrics', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'metrics', exact: true }).click(); + const res = page.waitForResponse((r) => r.url().endsWith('/api/apps/e2e-nginx/metrics?range=6h')); + await page.getByRole('button', { name: '6h', exact: true }).click(); + const metrics = await res; + expect(metrics.status()).toBe(200); + expect(Object.keys((await metrics.json()).containers || {}).length).toBeGreaterThan(0); + }); - // Generate HTTP load through the proxy. + test('proxied requests and container stats land in the metrics store', async () => { + const appId = getAppId('e2e-nginx'); + const since = Math.floor(Date.now() / 1000) - 1; for (let i = 0; i < 30; i++) { - try { - await fetchViaProxy('nginx-test.local', `/?i=${i}`); - } catch {} + const r = await fetchViaProxy('nginx-test.local', `/?i=${i}`); + expect(r.status).toBe(200); } - - // Wait for a couple of metrics + request-stats flush cycles. e2e runs - // both at 1s (SIMPLEDEPLOY_*_FLUSH_INTERVAL), so 5s is ample. - await new Promise((r) => setTimeout(r, 5_000)); - - const rows = sqliteQuery( - "SELECT COUNT(*) AS c FROM metrics WHERE app_id=(SELECT id FROM apps WHERE slug='e2e-nginx')", - ); - expect(rows.length).toBeGreaterThan(0); - const c = Number(rows[0].c); - expect(c).toBeGreaterThan(0); + // Request stats flush every 1s in e2e (SIMPLEDEPLOY_REQUEST_METRICS_FLUSH_INTERVAL). + await expect.poll(() => Number(sqliteQuery( + `SELECT COALESCE(SUM(count), 0) AS c FROM request_metrics WHERE app_id=${appId} AND tier='raw' AND ts >= ${since}`, + )[0].c), { timeout: 15_000 }).toBeGreaterThanOrEqual(30); const res = await apiRequest('GET', '/api/apps/e2e-nginx/metrics?range=1h'); - expect(res.ok).toBe(true); - expect(res.data).toBeTruthy(); - expect(res.data.containers).toBeTruthy(); - const cids = Object.keys(res.data.containers || {}); - expect(cids.length).toBeGreaterThan(0); - let sawCpu = false; - for (const cid of cids) { - const pts = res.data.containers[cid].points || []; - for (const p of pts) { - if (typeof p.c === 'number' && p.c >= 0) { - sawCpu = true; - break; - } - } - if (sawCpu) break; - } - expect(sawCpu).toBe(true); - - const reqRows = sqliteQuery( - "SELECT COUNT(*) AS c FROM request_metrics WHERE app_id=(SELECT id FROM apps WHERE slug='e2e-nginx')", - ); - expect(reqRows.length).toBeGreaterThan(0); - expect(Number(reqRows[0].c)).toBeGreaterThan(0); + expect(res.status).toBe(200); + const points = Object.values(res.data.containers || {}).flatMap((c) => c.points || []); + expect(points.some((p) => typeof p.c === 'number' && p.c >= 0)).toBe(true); }); }); diff --git a/e2e/tests/11-app-versions.spec.js b/e2e/tests/11-app-versions.spec.js index cd6a291f..c2e728bf 100644 --- a/e2e/tests/11-app-versions.spec.js +++ b/e2e/tests/11-app-versions.spec.js @@ -1,128 +1,46 @@ import { test, expect } from '@playwright/test'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; -import { findServiceContainer, containerImage } from '../helpers/docker.js'; - -test.describe('Deploy History & Rollback', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.locator('button').filter({ hasText: 'settings' }).click(); - }); - - test('shows deploy history', async ({ page }) => { - // Deploy History is a collapsible section with text "Deploy History (N)" - // It only appears if versions > 0; wait for config tab to finish loading - const historyBtn = page.locator('button').filter({ hasText: /Deploy History/i }); - await expect(historyBtn).toBeVisible({ timeout: 10_000 }); - }); - - test('deploy history has entries', async ({ page }) => { - // Expand the deploy history section - const historyBtn = page.locator('button').filter({ hasText: /Deploy History/i }); - await historyBtn.click(); - // Entries now display as timeline items with "v1", "v2", etc. in a span. - await expect(page.locator('span').filter({ hasText: /^v\d+$/ }).first()).toBeVisible({ timeout: 5_000 }); - }); -}); - -test.describe('Versions - Functional', () => { - test.describe.configure({ mode: 'serial' }); +import { apiLogin, apiRequest, getSessionCookie } from '../helpers/api.js'; +// e2e-nginx has two compose versions by now: the 03-deploy fixture +// (nginx:alpine) and the 07-app-config Save & Deploy (nginx:1.27-alpine). +test.describe('Deploy History', () => { test.beforeAll(async () => { await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('new deployment creates new version with updated image', async () => { - // Baseline image (should be nginx:alpine from original fixture) - const containerBefore = findServiceContainer('e2e-nginx', 'web'); - expect(containerBefore, 'expected e2e-nginx web container').toBeTruthy(); - const imageBefore = containerImage(containerBefore); - expect(imageBefore).toContain('nginx'); - - // Version count before redeploy - const beforeRes = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(beforeRes.ok).toBeTruthy(); - const countBefore = beforeRes.data.length; - - // Redeploy with pinned tag nginx:1.27-alpine. Deploy endpoint accepts base64 compose. - const newCompose = [ - 'services:', - ' web:', - ' image: nginx:1.27-alpine', - ' ports:', - ' - "8091:80"', - ' labels:', - ' simpledeploy.endpoints.0.domain: "nginx-test.local"', - ' simpledeploy.endpoints.0.port: "80"', - ' simpledeploy.endpoints.0.tls: "off"', - '', - ].join('\n'); - const b64 = Buffer.from(newCompose, 'utf-8').toString('base64'); - const deployRes = await apiRequest('POST', '/api/apps/deploy', { - name: 'e2e-nginx', - compose: b64, - force: true, - }); - expect(deployRes.status).toBe(202); - - // Wait for app to be running with new image (poll up to 60s) - const deadline = Date.now() + 90_000; - let currentImage = imageBefore; - let containerNow = containerBefore; - while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 2_000)); - containerNow = findServiceContainer('e2e-nginx', 'web'); - if (containerNow) { - try { - currentImage = containerImage(containerNow); - if (currentImage.includes('1.27')) break; - } catch {} - } - } - expect(currentImage).toContain('nginx:1.27-alpine'); - - // Wait for app status to stabilise before asserting versions - await waitForAppStatus('e2e-nginx', 'running', 60_000).catch(() => {}); - - // Versions list now has at least countBefore+1 (and newest-first) - const afterRes = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(afterRes.ok).toBeTruthy(); - expect(Array.isArray(afterRes.data)).toBe(true); - expect(afterRes.data.length).toBeGreaterThanOrEqual(countBefore + 1); - expect(afterRes.data.length).toBeGreaterThanOrEqual(2); - - // Newest first: compare first two timestamps (ms precision) - if (afterRes.data.length >= 2) { - const t0 = new Date(afterRes.data[0].created_at).getTime(); - const t1 = new Date(afterRes.data[1].created_at).getTime(); - expect(t0).toBeGreaterThanOrEqual(t1); + test('versions are newest first and the latest downloads as an attachment', async () => { + const res = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); + expect(res.status).toBe(200); + const versions = res.data; + expect(versions.length).toBeGreaterThanOrEqual(2); + for (let i = 1; i < versions.length; i++) { + expect(versions[i - 1].version).toBeGreaterThan(versions[i].version); + expect(new Date(versions[i - 1].created_at).getTime()).toBeGreaterThanOrEqual(new Date(versions[i].created_at).getTime()); } + const latest = versions[0]; + const oldest = versions[versions.length - 1]; + expect(latest.content).toMatch(/image: (\S+\/)?nginx:1\.27-alpine\n/); + expect(oldest.content).toMatch(/image: (\S+\/)?nginx:alpine\n/); - // Latest version's compose contains the new image tag - const latest = afterRes.data[0]; - expect(typeof latest.content).toBe('string'); - expect(latest.content).toContain('nginx:1.27-alpine'); + const dl = await fetch(`${getState().baseURL}/api/apps/e2e-nginx/versions/${latest.id}/download`, { + headers: { Cookie: getSessionCookie() }, + }); + expect(dl.status).toBe(200); + expect(dl.headers.get('content-disposition')).toMatch(/^attachment; filename=/); + expect(await dl.text()).toBe(latest.content); }); - test('version compose content is retrievable and valid', async () => { - const listRes = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(listRes.ok).toBeTruthy(); - expect(listRes.data.length).toBeGreaterThanOrEqual(1); - const latest = listRes.data[0]; + test('settings deploy history lists every stored version', async ({ page }) => { + const res = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); + expect(res.status).toBe(200); + const versions = res.data; - // Inline content from list - expect(typeof latest.content).toBe('string'); - expect(latest.content.length).toBeGreaterThan(0); - expect(latest.content).toMatch(/services\s*:/); - expect(latest.content).toContain('web'); - - // Download endpoint returns raw YAML (apiRequest falls through to text on non-JSON) - const dlRes = await apiRequest('GET', `/api/apps/e2e-nginx/versions/${latest.id}/download`); - expect(dlRes.ok).toBeTruthy(); - const body = typeof dlRes.data === 'string' ? dlRes.data : JSON.stringify(dlRes.data); - expect(body).toMatch(/services\s*:/); - expect(body).toContain('web'); + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'settings', exact: true }).click(); + const main = page.locator('main'); + await main.getByRole('button', { name: `Deploy History (${versions.length})` }).click(); + await expect(main.locator('span').filter({ hasText: /^v\d+$/ })).toHaveText(versions.map((v) => `v${v.version}`)); }); }); diff --git a/e2e/tests/12-backups.spec.js b/e2e/tests/12-backups.spec.js index b16b9818..317ad8e7 100644 --- a/e2e/tests/12-backups.spec.js +++ b/e2e/tests/12-backups.spec.js @@ -1,188 +1,135 @@ import { test, expect } from '@playwright/test'; import { createHash } from 'crypto'; -import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; +import { execFileSync } from 'child_process'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest, apiDownload, apiUploadMultipart } from '../helpers/api.js'; +import { findServiceContainer, dockerExec, dockerInspect, psql, waitForHealthy } from '../helpers/docker.js'; import { - apiLogin, - apiRequest, - apiDownload, - apiUploadMultipart, - waitForAppStatus, -} from '../helpers/api.js'; -import { - findServiceContainer, - dockerExec, - psql, - containerRunning, - waitForContainerState, - waitForHealthy, -} from '../helpers/docker.js'; - -const FIXTURES = join(import.meta.dirname, '..', 'fixtures'); - -function readFixture(name) { - return readFileSync(join(FIXTURES, name), 'utf-8'); -} - -async function pollRunStatus(slug, runId, desired, timeoutMs) { - const deadline = Date.now() + timeoutMs; - let last = null; - while (Date.now() < deadline) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - if (res.ok && Array.isArray(res.data)) { - const run = res.data.find((r) => r.id === runId); - if (run) { - last = run; - if (Array.isArray(desired) ? desired.includes(run.status) : run.status === desired) { - return run; - } - } - } - await new Promise((r) => setTimeout(r, 1_000)); - } - throw new Error(`run ${runId} did not reach status ${desired} within ${timeoutMs}ms (last=${JSON.stringify(last)})`); -} - -async function waitForNewRunForConfig(slug, configId, sinceCount, timeoutMs) { - // The API groups runs per-config; `data[0]` is NOT guaranteed to be the - // globally newest run. Filter to the target config explicitly. - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - if (res.ok && Array.isArray(res.data)) { - const forCfg = res.data.filter((r) => r.backup_config_id === configId); - if (forCfg.length > sinceCount) { - // Sort by started_at DESC just in case - forCfg.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at))); - return forCfg[0]; - } - } - await new Promise((r) => setTimeout(r, 500)); - } - throw new Error(`no new run for config ${configId} in ${slug} within ${timeoutMs}ms`); -} - -async function triggerAndWait(slug, configId, timeoutMs = 180_000) { - const before = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - const beforeCount = Array.isArray(before.data) - ? before.data.filter((r) => r.backup_config_id === configId).length - : 0; - const trig = await apiRequest('POST', `/api/backups/configs/${configId}/run`); - expect([200, 202]).toContain(trig.status); - const newest = await waitForNewRunForConfig(slug, configId, beforeCount, 15_000); - return await pollRunStatus(slug, newest.id, ['success', 'failed'], timeoutMs); + createBackupConfig, + deleteAppConfigsFor, + listRuns, + localBackupExists, + localBackupPath, + pollRunStatus, + readOr, + triggerAndWait, + waitForNewRunForConfig, +} from '../helpers/backups.js'; + +const POSTGRES_SLUG = 'e2e-postgres'; + +async function openBackupsTab(page) { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/${POSTGRES_SLUG}`); + await page.getByRole('button', { name: /^backups$/i }).click(); } -async function deleteAppConfigsFor(slug) { +async function listConfigs(slug) { const res = await apiRequest('GET', `/api/apps/${slug}/backups/configs`); - if (!res.ok || !Array.isArray(res.data)) return; - for (const c of res.data) { - await apiRequest('DELETE', `/api/backups/configs/${c.id}`); - } + expect(res.status).toBe(200); + return res.data; } test.describe('Backups', () => { - test('navigate to postgres app backups tab', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - await expect(page.getByText(/backup|configure/i).first()).toBeVisible({ timeout: 5_000 }); - }); + test.describe.configure({ mode: 'serial' }); - test('detect strategies shows postgres and volume', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - - const configBtn = page.getByRole('button', { name: /configure backup|add config/i }); - await configBtn.click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 }); - - // Strategy detection should show PostgreSQL and a volume/snapshot strategy. - // Backend label is "Volume Snapshot"; legacy UI label is "Files & Volumes". - await expect(page.getByText(/postgresql/i)).toBeVisible({ timeout: 5_000 }); - await expect(page.getByText(/(volume|files.*volumes)/i).first()).toBeVisible(); + let configId = null; + + test.beforeAll(async () => { + const login = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect(login.status).toBe(200); + await deleteAppConfigsFor(POSTGRES_SLUG); }); test('create backup config via wizard', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - - const configBtn = page.getByRole('button', { name: /configure backup|add config/i }); - await configBtn.click(); + await openBackupsTab(page); + await page.getByRole('button', { name: 'Configure Backup' }).click(); const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible({ timeout: 5_000 }); - - // Step 1: Select postgres strategy (auto-detected, may already be selected) - const pgBtn = dialog.getByText(/postgresql/i).first(); - if (await pgBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await pgBtn.click(); - } - await dialog.getByRole('button', { name: /next/i }).click(); + await expect(dialog).toBeVisible(); - // Step 2: Select local storage (default) - await dialog.getByText(/local storage/i).click(); - await dialog.getByRole('button', { name: /next/i }).click(); + // Step 1: strategy, detected from the compose file + const pgCard = dialog.getByRole('button', { name: /^PostgreSQL/ }); + await expect(pgCard).toContainText('Detected'); + await pgCard.click(); + await dialog.getByRole('button', { name: 'Next' }).click(); - // Step 3: Schedule (accept defaults) - await dialog.getByRole('button', { name: /next/i }).click(); + // Step 2: storage + await dialog.getByRole('button', { name: /Local Storage/ }).click(); + await dialog.getByRole('button', { name: 'Next' }).click(); - // Step 4: Hooks (skip) - await dialog.getByRole('button', { name: /next/i }).click(); + // Steps 3-5: schedule, hooks, retention (defaults) + for (let i = 0; i < 3; i++) { + await dialog.getByRole('button', { name: 'Next' }).click(); + } - // Step 5: Retention (accept defaults) - await dialog.getByRole('button', { name: /next/i }).click(); + // Step 6: review + await dialog.getByRole('button', { name: 'Create Backup' }).click(); + await expect(dialog).toBeHidden(); - // Step 6: Review and create - await dialog.getByRole('button', { name: /create backup/i }).click(); + const configs = await listConfigs(POSTGRES_SLUG); + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ strategy: 'postgres', target: 'local' }); + configId = configs[0].id; - // Verify config appears in the table - await expect(page.getByText(/local/i).first()).toBeVisible({ timeout: 10_000 }); + const row = page.locator('main table').first().getByRole('row').nth(1); + await expect(row).toContainText('Database (PostgreSQL)'); + await expect(row).toContainText('Local storage'); }); test('trigger manual backup', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - - const backupBtn = page.getByRole('button', { name: /backup now/i }); - await expect(backupBtn).toBeVisible({ timeout: 10_000 }); - await backupBtn.click(); - - // Wait for backup to run, then reload to see result - await page.waitForTimeout(3_000); - await page.reload(); - await page.getByRole('button', { name: /backups/i }).click(); - await expect(page.getByText(/running|success|failed/i).first()).toBeVisible({ timeout: 15_000 }); + expect(configId, 'wizard config must exist').toBeTruthy(); + await openBackupsTab(page); + + const known = new Set((await listRuns(POSTGRES_SLUG)).map((r) => r.id)); + const trig = page.waitForResponse( + (r) => r.url().endsWith(`/api/backups/configs/${configId}/run`) && r.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Backup Now' }).click(); + expect((await trig).status()).toBe(202); + + const run = await waitForNewRunForConfig(POSTGRES_SLUG, configId, known); + const done = await pollRunStatus(POSTGRES_SLUG, run.id, ['success', 'failed'], 120_000); + expect(done.status, `run error: ${done.error_msg}`).toBe('success'); }); test('global backups page shows summary', async ({ page }) => { await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/backups`); - await expect(page.getByText(/total config/i).first()).toBeVisible({ timeout: 5_000 }); + await page.goto(`${getState().baseURL}/#/backups`); + const stat = (label) => + page.locator('main').getByText(label, { exact: true }).locator('xpath=following-sibling::div[1]'); + await expect(stat('Total Configs')).toHaveText('1'); + expect(Number(await stat('24h Successful').textContent())).toBeGreaterThanOrEqual(1); }); test('delete backup config', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - - const deleteBtn = page.getByRole('button', { name: /delete/i }).first(); - if (await deleteBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await deleteBtn.click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - await dialog.getByRole('button', { name: /delete|confirm/i }).click(); - } + expect(configId, 'wizard config must exist').toBeTruthy(); + await openBackupsTab(page); + + const configsTable = page.locator('main').getByRole('table').first(); + const row = configsTable.getByRole('row', { name: /Database \(PostgreSQL\)/ }); + await row.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('heading', { name: 'No backups configured' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Backup Now' })).toHaveCount(0); + expect(await listConfigs(POSTGRES_SLUG)).toEqual([]); + }); + + test('backups tab shows the finished run without a reload', async ({ page }) => { + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'postgres' }); + try { + await openBackupsTab(page); + const known = new Set((await listRuns(POSTGRES_SLUG)).map((r) => r.id)); + await page.getByRole('button', { name: 'Backup Now' }).click(); + + const run = await waitForNewRunForConfig(POSTGRES_SLUG, cfgId, known); + const done = await pollRunStatus(POSTGRES_SLUG, run.id, ['success', 'failed'], 120_000); + expect(done.status, `run error: ${done.error_msg}`).toBe('success'); + + const history = page.locator('main').getByRole('table').nth(1); + await expect(history.getByRole('row').nth(1)).toContainText('success'); + } finally { + await apiRequest('DELETE', `/api/backups/configs/${cfgId}`); } }); }); @@ -190,363 +137,130 @@ test.describe('Backups', () => { test.describe('Backups - Functional Roundtrip', () => { test.describe.configure({ mode: 'serial' }); - const POSTGRES_SLUG = 'e2e-postgres'; - const VOLUME_SLUG = 'e2e-volume'; + const MARKER_SQL = "CREATE TABLE IF NOT EXISTS e2e_data(id INT, v TEXT); DELETE FROM e2e_data; INSERT INTO e2e_data VALUES (1, 'roundtrip-marker-abc123');"; + const SELECT_SQL = "SELECT id||'|'||v FROM e2e_data WHERE id=1;"; let pgContainer = null; - let volContainer = null; - let postgresConfigId = null; - let volumeConfigId = null; - let pgSuccessRunId = null; test.beforeAll(async () => { - test.setTimeout(300_000); const login = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - expect(login.ok).toBeTruthy(); + expect(login.status).toBe(200); - // Locate postgres container (deployed in earlier spec) pgContainer = findServiceContainer(POSTGRES_SLUG, 'db'); expect(pgContainer, 'e2e-postgres db container must be running').toBeTruthy(); - // Clean any pre-existing configs from earlier UI tests await deleteAppConfigsFor(POSTGRES_SLUG); + }); - // Volume strategy roundtrip uses the e2e-postgres pgdata volume directly. - // (Avoids a separate e2e-volume app deploy which can race with the - // reconciler during a busy test session.) The postgres container has - // /var/lib/postgresql/data mounted from the pgdata volume; we seed and - // restore a test file there for the volume strategy assertion. - volContainer = pgContainer; - await waitForHealthy(volContainer, 'test -d /var/lib/postgresql/data', 30_000); + test.afterAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + await deleteAppConfigsFor(POSTGRES_SLUG); }); test('1. postgres data roundtrip: insert, backup, drop, restore, verify', async () => { - test.setTimeout(300_000); + psql(pgContainer, 'postgres', 'testdb', MARKER_SQL); + expect(psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)).toBe('1|roundtrip-marker-abc123'); - // 1. Seed data - psql(pgContainer, 'postgres', 'testdb', - "CREATE TABLE IF NOT EXISTS e2e_data(id INT, v TEXT); DELETE FROM e2e_data; INSERT INTO e2e_data VALUES (1, 'roundtrip-marker-abc123');"); - const seededOut = psql(pgContainer, 'postgres', 'testdb', "SELECT id||'|'||v FROM e2e_data WHERE id=1;"); - expect(seededOut).toContain('1|roundtrip-marker-abc123'); - - // 2. Create backup config - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { - strategy: 'postgres', - target: 'local', - schedule_cron: '0 0 1 1 *', // yearly on Jan 1; effectively won't auto-trigger - target_config_json: '', - retention_mode: 'count', - retention_count: 5, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', - }); - expect(createRes.ok).toBeTruthy(); - expect(createRes.data.id).toBeGreaterThan(0); - postgresConfigId = createRes.data.id; - - // 3. Trigger manual backup and wait for success - const run = await triggerAndWait(POSTGRES_SLUG, postgresConfigId, 180_000); - expect(run.status).toBe('success'); + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'postgres' }); + const run = await triggerAndWait(POSTGRES_SLUG, cfgId); + expect(run.status, `run error: ${run.error_msg}`).toBe('success'); expect(run.size_bytes).toBeGreaterThan(0); - expect(run.checksum).toBeTruthy(); expect(run.checksum.length).toBeGreaterThan(16); - expect(run.file_path).toBeTruthy(); - pgSuccessRunId = run.id; + expect(localBackupExists(run), `backup file ${localBackupPath(run)}`).toBe(true); - // 4. Drop table psql(pgContainer, 'postgres', 'testdb', 'DROP TABLE e2e_data;'); - let errored = false; - try { - psql(pgContainer, 'postgres', 'testdb', 'SELECT * FROM e2e_data;'); - } catch { - errored = true; - } - expect(errored).toBeTruthy(); + expect(() => psql(pgContainer, 'postgres', 'testdb', 'SELECT * FROM e2e_data;')).toThrow(/does not exist/); - // 5. Restore const restoreRes = await apiRequest('POST', `/api/backups/restore/${run.id}`); expect(restoreRes.status).toBe(202); - - // 6. Poll until table comes back (restore is async) - const restoreDeadline = Date.now() + 60_000; - let restoredOut = ''; - while (Date.now() < restoreDeadline) { - try { - restoredOut = psql(pgContainer, 'postgres', 'testdb', "SELECT id||'|'||v FROM e2e_data WHERE id=1;"); - if (restoredOut.includes('roundtrip-marker-abc123')) break; - } catch { - // table may not exist yet - } - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(restoredOut).toContain('1|roundtrip-marker-abc123'); - }); - - test('2. backup file checksum matches SHA-256 of downloaded bytes', async () => { - expect(pgSuccessRunId, 'prior postgres backup must exist').toBeTruthy(); - test.setTimeout(60_000); - - const runRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/runs`); - expect(runRes.ok).toBeTruthy(); - const run = runRes.data.find((r) => r.id === pgSuccessRunId); - expect(run).toBeTruthy(); - expect(run.checksum).toBeTruthy(); - - const dl = await apiDownload(`/api/backups/runs/${pgSuccessRunId}/download`); - expect(dl.ok, `download failed: status=${dl.status} body=${dl.body}`).toBeTruthy(); - expect(dl.buffer.length).toBeGreaterThan(0); - - const actual = createHash('sha256').update(dl.buffer).digest('hex'); - // run.checksum may be prefixed (e.g. "sha256:...") or raw hex; compare both - const stored = run.checksum.replace(/^sha256:/i, '').toLowerCase(); - expect(actual.toLowerCase()).toBe(stored); + await expect + .poll(() => readOr(() => psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)), { timeout: 60_000 }) + .toBe('1|roundtrip-marker-abc123'); }); - test('3. volume strategy captures files into a valid tar.gz archive', async () => { - // A full restore-over-live-postgres cycle is racy (pg keeps files open). - // Instead we verify the volume backup produces a valid tar.gz archive - // that contains the seeded marker. Read the file directly from disk. - test.setTimeout(300_000); - + test('2. volume strategy captures files into a valid tar.gz archive', async () => { + // Restoring over a live postgres data dir is racy (pg keeps files open), + // so verify the archive contents instead of a full roundtrip. const volDir = '/var/lib/postgresql/data'; const markerPath = `${volDir}/e2e_marker.txt`; + dockerExec(pgContainer, `echo volume-marker-xyz > ${markerPath}`); - dockerExec(volContainer, `echo volume-marker-xyz > ${markerPath}`); - expect(dockerExec(volContainer, `cat ${markerPath}`).trim()).toBe('volume-marker-xyz'); - - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { - strategy: 'volume', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 5, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: JSON.stringify([volDir]), - }); - expect(createRes.ok).toBeTruthy(); - volumeConfigId = createRes.data.id; - - const run = await triggerAndWait(POSTGRES_SLUG, volumeConfigId, 180_000); - expect(run.status).toBe('success'); - expect(run.size_bytes).toBeGreaterThan(0); - - // Find the backup file on disk under ${state.dataDir}/backups. run.file_path - // is just the filename for the local target. - const state = getState(); - const { execFileSync } = await import('child_process'); - const filename = (run.file_path || '').split('/').pop(); - expect(filename, 'run must have a file_path').toBeTruthy(); - const absPath = `${state.dataDir}/backups/${filename}`; - - const list = execFileSync('tar', ['-tzf', absPath], { encoding: 'utf-8' }); - expect(list).toContain('e2e_marker.txt'); - - const extracted = execFileSync('tar', ['-xzOf', absPath, 'var/lib/postgresql/data/e2e_marker.txt'], { - encoding: 'utf-8', - }).trim(); - expect(extracted).toBe('volume-marker-xyz'); - - try { dockerExec(volContainer, `rm -f ${markerPath}`); } catch {} - }); - - test('4. pre/post hooks execute around postgres backup', async () => { - test.setTimeout(300_000); - - // Build hook config: stop db pre-backup, start db post-backup. - // NOTE: hook service names map to container names. The scheduler passes - // app.Name as ContainerName; for hooks we target the actual service container. - const preHooks = JSON.stringify([ - { type: 'stop', service: pgContainer }, - ]); - const postHooks = JSON.stringify([ - { type: 'start', service: pgContainer }, - ]); - - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { - strategy: 'postgres', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 3, - verify_upload: false, - pre_hooks: preHooks, - post_hooks: postHooks, - paths: '', - }); - expect(createRes.ok).toBeTruthy(); - const hookCfgId = createRes.data.id; - - // Trigger and observe state transitions. The backup itself will likely fail - // because the container is stopped during backup (pg_dump needs running pg), - // but the post-hook should restart it and we can assert container recovered. - const wasRunning = containerRunning(pgContainer); - expect(wasRunning).toBeTruthy(); - - const trig = await apiRequest('POST', `/api/backups/configs/${hookCfgId}/run`); - expect([200, 202]).toContain(trig.status); - - // Observe a stopped state (pre-hook ran) - let sawStopped = false; - const stopDeadline = Date.now() + 30_000; - while (Date.now() < stopDeadline) { - if (containerRunning(pgContainer) === false) { sawStopped = true; break; } - await new Promise((r) => setTimeout(r, 250)); - } - - // Whether or not we caught the stopped window, wait for container to be running again (post-hook) - await waitForContainerState(pgContainer, true, 60_000); - - // Wait for postgres to accept connections again - await waitForHealthy(pgContainer, 'pg_isready -U postgres', 60_000); - - // Assert we observed the stop (pre-hook) OR that the run recorded hook execution. - // If the backup was too fast to catch the stopped state, at minimum the run record - // should exist. - const runsRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/runs`); - expect(runsRes.ok).toBeTruthy(); - const hookRun = runsRes.data.find((r) => r.backup_config_id === hookCfgId); - expect(hookRun).toBeTruthy(); - - // Post-hook must have restarted the container (running now) - expect(containerRunning(pgContainer)).toBeTruthy(); - - // Cleanup hook config so retention test starts clean - await apiRequest('DELETE', `/api/backups/configs/${hookCfgId}`); - }); - - test('5. retention_count=2 prunes older successful runs', async () => { - test.setTimeout(600_000); - - // Fresh config with retention_count=2 - await deleteAppConfigsFor(POSTGRES_SLUG); - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { - strategy: 'postgres', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 2, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', - }); - expect(createRes.ok).toBeTruthy(); - const retCfgId = createRes.data.id; - - const filePaths = []; - for (let i = 0; i < 3; i++) { - const run = await triggerAndWait(POSTGRES_SLUG, retCfgId, 180_000); - expect(run.status).toBe('success'); - if (run.file_path) filePaths.push(run.file_path); - // Ensure different timestamp on filename (postgres strategy uses YYYYMMDD-HHMMSS) - await new Promise((r) => setTimeout(r, 1500)); - } - - const runsRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/runs`); - expect(runsRes.ok).toBeTruthy(); - const successRuns = runsRes.data.filter( - (r) => r.backup_config_id === retCfgId && r.status === 'success', - ); - - // Scheduler prunes files on disk for runs beyond retention_count, - // though the DB rows remain. Assert at most 2 on-disk files exist. - let onDiskCount = 0; - for (const p of filePaths) { - if (p && existsSync(p)) onDiskCount += 1; + try { + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'volume', paths: JSON.stringify([volDir]) }); + const run = await triggerAndWait(POSTGRES_SLUG, cfgId); + expect(run.status, `run error: ${run.error_msg}`).toBe('success'); + expect(run.size_bytes).toBeGreaterThan(0); + + const absPath = localBackupPath(run); + const list = execFileSync('tar', ['-tzf', absPath], { encoding: 'utf-8' }); + expect(list).toContain('var/lib/postgresql/data/e2e_marker.txt'); + const extracted = execFileSync('tar', ['-xzOf', absPath, 'var/lib/postgresql/data/e2e_marker.txt'], { + encoding: 'utf-8', + }).trim(); + expect(extracted).toBe('volume-marker-xyz'); + } finally { + dockerExec(pgContainer, `rm -f ${markerPath}`); } - expect(onDiskCount).toBeLessThanOrEqual(2); - // DB row count should be at least 3 (we triggered 3); success rows exist for all - expect(successRuns.length).toBeGreaterThanOrEqual(3); - - // Save config id so next test can reuse it for download/upload cycle - postgresConfigId = retCfgId; }); - test('6. download → upload-restore cycle via multipart', async () => { - test.setTimeout(300_000); - - // Find any successful postgres run whose on-disk file still exists. - // run.file_path stores the filename only; resolve against dataDir/backups. - const runsRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/runs`); - expect(runsRes.ok).toBeTruthy(); - const state = getState(); - const candidates = runsRes.data.filter((r) => { - if (r.status !== 'success' || !r.file_path) return false; - if (!r.file_path.endsWith('.sql.gz')) return false; - const abs = `${state.dataDir}/backups/${r.file_path.split('/').pop()}`; - return existsSync(abs); - }); - expect(candidates.length).toBeGreaterThan(0); - const run = candidates[0]; + test('3. download, checksum, upload-restore cycle via multipart', async () => { + const runs = await listRuns(POSTGRES_SLUG); + const run = runs.find((r) => r.status === 'success' && r.file_path.endsWith('.sql.gz') && localBackupExists(r)); + expect(run, 'a postgres run from test 1 with its file on disk').toBeTruthy(); - // Re-seed the table so we can prove restore worked after drop - psql(pgContainer, 'postgres', 'testdb', - "CREATE TABLE IF NOT EXISTS e2e_data(id INT, v TEXT); DELETE FROM e2e_data; INSERT INTO e2e_data VALUES (1, 'roundtrip-marker-abc123');"); + psql(pgContainer, 'postgres', 'testdb', MARKER_SQL); - // Download const dl = await apiDownload(`/api/backups/runs/${run.id}/download`); - expect(dl.ok, `download failed: status=${dl.status}`).toBeTruthy(); - expect(dl.buffer.length).toBeGreaterThan(0); + expect(dl.status, `download failed: ${dl.body}`).toBe(200); + const actual = createHash('sha256').update(dl.buffer).digest('hex'); + expect(actual).toBe(run.checksum.replace(/^sha256:/i, '').toLowerCase()); - // Drop table psql(pgContainer, 'postgres', 'testdb', 'DROP TABLE e2e_data;'); - // Upload-restore. The downloaded filename should end in .sql.gz. - const fileName = run.file_path.split('/').pop() || 'backup.sql.gz'; const up = await apiUploadMultipart( `/api/apps/${POSTGRES_SLUG}/backups/upload-restore`, { strategy: 'postgres', container: pgContainer }, 'file', - fileName, + run.file_path.split('/').pop(), dl.buffer, ); - expect([200, 202]).toContain(up.status); - - // Poll until data comes back - const deadline = Date.now() + 90_000; - let got = ''; - while (Date.now() < deadline) { - try { - got = psql(pgContainer, 'postgres', 'testdb', "SELECT id||'|'||v FROM e2e_data WHERE id=1;"); - if (got.includes('roundtrip-marker-abc123')) break; - } catch {} - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(got).toContain('roundtrip-marker-abc123'); - }); + expect(up.status, `upload-restore: ${JSON.stringify(up.data)}`).toBe(202); - test('7. strategy detection reports postgres and volume for postgres app', async () => { - const res = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/detect`); - expect(res.ok).toBeTruthy(); - const strategies = res.data.strategies || []; - const byType = Object.fromEntries(strategies.map((s) => [s.strategy_type, s])); - - // Postgres strategy should be available with a postgres service - expect(byType.postgres, 'postgres strategy missing').toBeTruthy(); - expect(byType.postgres.available).toBe(true); - expect(Array.isArray(byType.postgres.services)).toBe(true); - expect(byType.postgres.services.length).toBeGreaterThan(0); - const pgSvc = byType.postgres.services[0]; - expect(pgSvc.container_name).toContain('db'); - - // Volume strategy should also surface for pgdata volume mount - expect(byType.volume, 'volume strategy missing').toBeTruthy(); - expect(byType.volume.available).toBe(true); - expect(byType.volume.services.length).toBeGreaterThan(0); + await expect + .poll(() => readOr(() => psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)), { timeout: 90_000 }) + .toBe('1|roundtrip-marker-abc123'); }); - test.afterAll(async () => { - // Best-effort cleanup of backup configs so later specs have a clean slate. + test('4. pre/post hooks run in the app container around the backup', async () => { + // Hooks name compose services. The pre-hook leaves a marker; the post + // hooks restart the db service (so its StartedAt changes) and then leave + // a second marker, which needs the restarted container to be running. + const tag = Date.now(); + const preMarker = `/tmp/e2e-pre-hook-${tag}`; + const postMarker = `/tmp/e2e-post-hook-${tag}`; + const startedBefore = dockerInspect(pgContainer).State.StartedAt; + + const cfgId = await createBackupConfig(POSTGRES_SLUG, { + strategy: 'postgres', + pre_hooks: JSON.stringify([{ type: 'exec', service: 'db', command: `touch ${preMarker}` }]), + post_hooks: JSON.stringify([ + { type: 'stop', service: 'db' }, + { type: 'start', service: 'db' }, + { type: 'exec', service: 'db', command: `touch ${postMarker}` }, + ]), + }); + try { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - await deleteAppConfigsFor(POSTGRES_SLUG); - } catch {} + const run = await triggerAndWait(POSTGRES_SLUG, cfgId); + expect(run.status, `run error: ${run.error_msg}`).toBe('success'); + await waitForHealthy(pgContainer, 'pg_isready -U postgres', 60_000); + + expect(() => dockerExec(pgContainer, `test -f ${preMarker}`), 'pre-hook marker').not.toThrow(); + expect(() => dockerExec(pgContainer, `test -f ${postMarker}`), 'post-hook marker').not.toThrow(); + expect(dockerInspect(pgContainer).State.StartedAt, 'post-hooks restart db').not.toBe(startedBefore); + } finally { + await apiRequest('DELETE', `/api/backups/configs/${cfgId}`); + await waitForHealthy(pgContainer, 'pg_isready -U postgres', 60_000); + } }); }); diff --git a/e2e/tests/13-alerts.spec.js b/e2e/tests/13-alerts.spec.js index 5cfc6391..f50007dc 100644 --- a/e2e/tests/13-alerts.spec.js +++ b/e2e/tests/13-alerts.spec.js @@ -1,352 +1,288 @@ import { test, expect } from '@playwright/test'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest } from '../helpers/api.js'; +import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; import { startWebhookReceiver } from '../helpers/webhook.js'; -import { - sqliteQuery, - sqliteExec, - injectHighCPUWindow, - insertMetricPoint, - getAppId, - replaceMetricsAtomic, -} from '../helpers/db.js'; +import { sqliteQuery, sqliteExec, getAppId } from '../helpers/db.js'; +import { deleteAppMetrics, startMetricRefresher, waitForCollectorCycle } from '../helpers/metrics.js'; +import { createBackupConfig } from '../helpers/backups.js'; + +const UI_HOOK = 'E2E Test Hook'; + +async function listWebhooks() { + const res = await apiRequest('GET', '/api/webhooks'); + expect(res.status).toBe(200); + return res.data; +} + +async function listRules() { + const res = await apiRequest('GET', '/api/alerts/rules'); + expect(res.status).toBe(200); + return res.data; +} + +async function createWebhook(body) { + const res = await apiRequest('POST', '/api/webhooks', body); + expect(res.status, `webhook create: ${JSON.stringify(res.data)}`).toBe(201); + return res.data.id; +} + +async function createRule(body) { + const res = await apiRequest('POST', '/api/alerts/rules', { operator: '>', enabled: true, ...body }); + expect(res.status, `rule create: ${JSON.stringify(res.data)}`).toBe(201); + return res.data.id; +} + +// ruleSelect finds a