From 9c508218e9904d2a5e9f6705a4db92d0e4a6b5e1 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 5 Oct 2026 18:04:02 -0700 Subject: [PATCH] e2e: scope the FrankBook-Pro leg to its own trigger-gated Playwright project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 11-admin spec walked links that terminate on FrankBook-Pro (192.168.0.112), which sleeps when idle — every deploy gate timed out there (measured 2026-10-05, two deploy windows). Split: the frankbook links move to tests/e2e/specs/19-frankbook.spec.ts under a frankbook project that run.sh excludes by default and deploy.sh triggers ONLY when a frankbook blast-radius path changed (the spec, frankbook-pro/, the homepage registry, the Caddyfile arr vhosts). deploy.sh computes the trigger from the same BASE..HEAD revision range the plan uses. --- scripts/deploy.sh | 27 ++++++++++- tests/e2e/playwright.config.ts | 11 +++++ tests/e2e/run.sh | 30 +++++++++++- tests/e2e/specs/11-admin.spec.ts | 25 ++++++++-- tests/e2e/specs/19-frankbook.spec.ts | 69 ++++++++++++++++++++++++++++ tests/e2e_scaffold.bats | 3 +- 6 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/specs/19-frankbook.spec.ts diff --git a/scripts/deploy.sh b/scripts/deploy.sh index efcc350..f0b62f5 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -196,6 +196,21 @@ else printf ' - %s\n' "${PLAN[@]}" fi +# --- frankbook e2e trigger -------------------------------------------------- +# The *arr/qBittorrent e2e units walk links that terminate on FrankBook-Pro +# (192.168.0.112), which sleeps when idle. The [3/3] e2e gate therefore runs +# the frankbook Playwright project ONLY when something in its blast radius +# changed since origin/main: the frankbook spec itself, the frankbook-pro +# stack, the homepage registry, or the Caddyfile serving the arr vhosts. +# Every other deploy skips it (tests/e2e/run.sh excludes the project by +# default). See tests/e2e/specs/19-frankbook.spec.ts. +FB_E2E_TRIGGER=0 +while IFS= read -r fb_path; do + case "$fb_path" in + tests/e2e/specs/19-frankbook.spec.ts|frankbook-pro/*|shared/homepage/services.json|dankclaw/Caddyfile) FB_E2E_TRIGGER=1 ;; + esac +done < <(git diff --name-only "$BASE_REVISION" "$HEAD_REVISION") + REMOTE="${REMOTE_USER}@${REMOTE_HOST}" REMOTE_ARGS=("$TARGET" "$HEAD_REVISION" "$REMOTE_DIR" "$REMOTE_SHELL") if [[ "${#EXPLICIT_SERVICES[@]}" -eq 0 ]]; then @@ -209,6 +224,11 @@ if [[ "$DRY_RUN" -eq 1 ]]; then printf '[dry-run] Runner arguments:' printf ' %q' "${REMOTE_ARGS[@]}" printf '\n' + if [[ "$FB_E2E_TRIGGER" -eq 1 ]]; then + echo "[dry-run] e2e gate: frankbook project INCLUDED (frankbook trigger paths changed)." + else + echo "[dry-run] e2e gate: frankbook project EXCLUDED (no frankbook trigger paths)." + fi exit 0 fi @@ -250,7 +270,12 @@ if [[ "$SKIP_E2E" -eq 1 ]]; then echo "[3/3] e2e gate SKIPPED (--skip-e2e): live fleet NOT verified." >&2 else echo "[3/3] Running Playwright e2e gate against the live fleet..." - if bash "$REPO_ROOT/tests/e2e/run.sh"; then + E2E_ENV=() + if [[ "$FB_E2E_TRIGGER" -eq 1 ]]; then + echo " frankbook trigger paths changed: including the frankbook e2e project." + E2E_ENV=(E2E_INCLUDE_FRANKBOOK=1) + fi + if env "${E2E_ENV[@]}" bash "$REPO_ROOT/tests/e2e/run.sh"; then echo "e2e gate PASSED." else echo "=== DEPLOY INCOMPLETE: e2e gate FAILED on $TARGET ===" >&2 diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index a9e9097..16f925d 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -45,5 +45,16 @@ export default defineConfig({ testMatch: /(14|15)-.*\.spec\.ts/, use: { storageState: { cookies: [], origins: [] } }, }, + { + name: "frankbook", + // The *arr/qBittorrent walk (19-). Excluded from default runs and from + // the deploy e2e gate unless a frankbook trigger path changed — the + // links terminate on FrankBook-Pro, which sleeps (see tests/e2e/run.sh + // and scripts/deploy.sh). Matched by number prefix like every project + // here; joined 2026-10-05. + testMatch: /19-.*\.spec\.ts/, + use: { storageState: ".auth/qa-e2e-admin.json" }, + dependencies: ["setup"], + }, ], }); diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 96586d8..90ada04 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -4,8 +4,14 @@ # part of ./tests/run.sh. Credentials live in tests/e2e/.env.local # (git-ignored; see .env.local.example). Usage: # -# tests/e2e/run.sh # full suite +# tests/e2e/run.sh # full suite (frankbook project excluded) # tests/e2e/run.sh --project guest # any playwright args pass through +# +# The frankbook project (19-frankbook.spec.ts — the *arr/qBittorrent walk) +# is NOT in the default project set: its links terminate on FrankBook-Pro, +# which sleeps when idle, so it must not gate unrelated deploys. deploy.sh +# sets E2E_INCLUDE_FRANKBOOK=1 when a frankbook trigger path changed. An +# explicit --project argument in "$@" bypasses the default set entirely. set -euo pipefail cd "$(dirname "$0")" @@ -24,4 +30,24 @@ fi npx playwright install chromium -exec npx playwright test "$@" +# Default project set; the frankbook project joins only when deploy.sh saw a +# frankbook trigger path (E2E_INCLUDE_FRANKBOOK=1). An explicit --project in +# "$@" takes over untouched. +DEFAULT_PROJECTS=(setup standard admin guest) +if [[ "${E2E_INCLUDE_FRANKBOOK:-0}" == "1" ]]; then + DEFAULT_PROJECTS+=(frankbook) +fi +has_project=0 +for arg in "$@"; do + case "$arg" in + --project|--project=*) has_project=1 ;; + esac +done +if [[ "$has_project" -eq 1 ]]; then + exec npx playwright test "$@" +fi +PROJECT_ARGS=() +for project in "${DEFAULT_PROJECTS[@]}"; do + PROJECT_ARGS+=(--project "$project") +done +exec npx playwright test "${PROJECT_ARGS[@]}" diff --git a/tests/e2e/specs/11-admin.spec.ts b/tests/e2e/specs/11-admin.spec.ts index 7f8ab24..912e852 100644 --- a/tests/e2e/specs/11-admin.spec.ts +++ b/tests/e2e/specs/11-admin.spec.ts @@ -24,10 +24,28 @@ test("admin panel renders admin-category services", async ({ page }) => { test("admin open links reach their apps through SSO", async ({ page }) => { test.setTimeout(240_000); // Expected link count comes from the live registry (admin showLink=true - // entries, now including the *arr vhosts that use their own auth). - const registry: { name: string; category: string; showLink: boolean }[] = - await (await page.request.get("/services.json")).json(); + // entries, now including the *arr vhosts that use their own auth). The + // frankbook-hosted links (probe on 192.168.0.112) are NOT walked here: + // they terminate on FrankBook-Pro, which sleeps when idle, so their walk + // lives in 19-frankbook.spec.ts (frankbook project — excluded from the + // default e2e run and from the deploy gate unless a frankbook trigger + // path changed; see tests/e2e/run.sh and scripts/deploy.sh). + const registry: { + name: string; + category: string; + showLink: boolean; + url: string; + probe: string; + }[] = await (await page.request.get("/services.json")).json(); const expectedAdminLinks = registry.filter((s) => s.category === "admin" && s.showLink).length; + const frankbookUrls = registry + .filter( + (s) => + s.category === "admin" && + s.showLink && + ((s.probe ?? "").startsWith("192.168.0.112") || s.url.includes("192.168.0.112")), + ) + .map((s) => new URL(s.url).origin); await page.goto("/admin/"); await expect(page.locator("#grid a.open")).toHaveCount(expectedAdminLinks); const hrefs = await page.locator("#grid a.open").evaluateAll((els) => @@ -35,6 +53,7 @@ test("admin open links reach their apps through SSO", async ({ page }) => { ); for (const href of hrefs) { + if (frankbookUrls.includes(new URL(href).origin)) continue; // 19-frankbook walks these const target = new URL(href); await page.goto(href); // Every guarded vhost runs its own Anubis proof-of-work on first visit. diff --git a/tests/e2e/specs/19-frankbook.spec.ts b/tests/e2e/specs/19-frankbook.spec.ts new file mode 100644 index 0000000..e32bcd2 --- /dev/null +++ b/tests/e2e/specs/19-frankbook.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; + +// The *arr / qBittorrent admin links terminate on FrankBook-Pro +// (192.168.0.112), a Mac mini that SLEEPS when idle: Caddy answers 502 for +// the vhosts and the raw Sonarr link times out. This walk therefore lives in +// its own project, excluded from the default e2e run (tests/e2e/run.sh) and +// from the deploy gate's invocation (scripts/deploy.sh) unless a frankbook +// trigger path changed: this spec, frankbook-pro/**, +// shared/homepage/services.json, or the dankclaw Caddyfile that serves the +// arr vhosts. A sleeping Mac mini must never fail an unrelated deploy. +// +// Assertions mirror 11-admin's "admin open links reach their apps through +// SSO": the guard chain settles (Anubis PoW where present — Sonarr's raw-IP +// link is internal-only and has none), the hostname holds, no authentik form +// appears, and the app answers below 500. + +const FRANKBOOK_IP = "192.168.0.112"; +const CHALLENGE = "Making sure you're not a bot"; + +interface RegistryEntry { + name: string; + category: string; + showLink: boolean; + url: string; + probe: string; +} + +test("frankbook admin open links reach their apps", async ({ page }) => { + test.setTimeout(240_000); + const registry: RegistryEntry[] = await (await page.request.get("/services.json")).json(); + const frankbook = registry.filter( + (s) => + s.category === "admin" && + s.showLink && + ((s.probe ?? "").startsWith(FRANKBOOK_IP) || s.url.includes(FRANKBOOK_IP)), + ); + const frankbookUrls = frankbook.map((s) => new URL(s.url).origin); + + await page.goto("/admin/"); + const hrefs = await page + .locator("#grid a.open") + .evaluateAll((els) => els.map((el) => (el as HTMLAnchorElement).href)); + const frankbookHrefs = hrefs.filter((href) => frankbookUrls.includes(new URL(href).origin)); + // The rendered frankbook link set must mirror the registry: a renamed arr + // vhost or a dropped probe fails here instead of silently dropping the + // walk's coverage. + expect(frankbookHrefs, "rendered frankbook links must match the registry").toHaveLength( + frankbook.length, + ); + + for (const href of frankbookHrefs) { + const target = new URL(href); + await page.goto(href); + // Guarded vhosts run their own Anubis proof-of-work on first visit; the + // raw-IP Sonarr link has none and settles immediately. + await expect + .poll( + () => page.locator("body").innerText().then((t) => !t.includes(CHALLENGE)), + { timeout: 90_000 }, + ) + .toBe(true); + await expect + .poll(() => new URL(page.url()).hostname, { timeout: 60_000 }) + .toBe(target.hostname); + expect(await page.locator('input[name="uidField"]').count()).toBe(0); + const resp = await page.request.get(page.url()); + expect(resp.status(), `${target.hostname} must answer, not error`).toBeLessThan(500); + } +}); diff --git a/tests/e2e_scaffold.bats b/tests/e2e_scaffold.bats index 2481c1b..68a0436 100755 --- a/tests/e2e_scaffold.bats +++ b/tests/e2e_scaffold.bats @@ -10,7 +10,8 @@ load helpers for f in run.sh package.json playwright.config.ts .env.local.example \ lib/env.ts lib/login.ts \ specs/00-auth.setup.ts specs/10-homepage.spec.ts specs/11-admin.spec.ts \ - specs/12-crawl.spec.ts specs/13-sso.spec.ts specs/14-guard.spec.ts; do + specs/12-crawl.spec.ts specs/13-sso.spec.ts specs/14-guard.spec.ts \ + specs/19-frankbook.spec.ts; do [ -f "$REPO_ROOT/tests/e2e/$f" ] || { echo "missing tests/e2e/$f" return 1