e2e: scope the FrankBook-Pro leg to its own trigger-gated Playwright project

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.
This commit is contained in:
Frank
2026-10-05 18:04:02 -07:00
parent 77decb545e
commit 9c508218e9
6 changed files with 158 additions and 7 deletions
+26 -1
View File
@@ -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
+11
View File
@@ -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"],
},
],
});
+28 -2
View File
@@ -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[@]}"
+22 -3
View File
@@ -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.
+69
View File
@@ -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);
}
});
+2 -1
View File
@@ -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