Compare commits

..

4 Commits

Author SHA1 Message Date
Ben Stull fbaa975b5c Merge pull request 'fix(§22.4a): scope RFCView entry-detail fetch to its collection (v0.52.1)' (#43) from fix-collection-scoped-entry-detail into main 2026-06-08 13:45:42 +00:00
Ben Stull ba37da927a fix(§22.4a): scope RFCView entry-detail fetch to its collection (v0.52.1)
The §9 deployed-environment E2E harness (0.52.0), run against a PPE host
with per-collection-isolated content, surfaced a latent multi-collection
bug: RFCView computed the collection id from the route but called
getRFC(pid, slug) without it, so a named-collection entry was always
fetched via the project default-collection route — which 404s for an entry
that exists only in a named collection ("Error: Not found"; metadata panel
absent). Local/Tier-1 stacks masked it (same slug also reachable via the
default collection). Thread cid through all three getRFC call sites; re-run
the load effect on collection change.

Harness/test-infra (not in the deployed artifact):
- e2e: pre-record cookie consent via addInitScript (lib/fixtures.js) so the
  bottom-fixed consent banner can't intercept catalog row-select clicks on
  the slower deployed edge.
- testing/seed-ppe.sh: fail loudly on any non-2xx Gitea response (a
  swallowed 403 org-repo create had reached the deploy as a 502).
- testing/ppe-deploy-and-test.sh: seed via the Keychain admin token
  (write:organization needed to create the PPE repos); store the E2E secret
  newline-free; read EXPECT_VERSION from VERSION.

Patch bump 0.52.0 → 0.52.1; CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:45:01 -07:00
Ben Stull 9c8035bdbd test(e2e): one-shot PPE deploy+E2E resume script
Runs the whole §9 PPE stage non-interactively after the operator's gcloud
reauth: bot-token-seed the PPE repos -> ensure E2E secret -> deploy ->
wait for health=0.52.0 + bdd-collection sync -> run metadata.spec.js
against the deployed host. Idempotent; secrets fetched from SM, never
echoed. Test/ops infra (not in the deployed artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:01:19 -07:00
Ben Stull fd123da6a3 test(e2e): harden harness for deployed runs (retries, serialize, banner)
Test-infra only (e2e/ is not in the deployed artifact). Refines the
v0.52.0 deployed-env harness:
- retries:2 + on-first-retry trace now meaningful (was dead: retries
  defaulted to 0); de-risks timing flakes over the public edge.
- workers:1 — the metadata specs run in order and write real commits;
  parallel workers would race on shared state and concurrent bot pushes.
- deployed timeouts bumped (60s/20s) when E2E_TEST_AUTH_SECRET is set.
- dismissCookies forcibly removes any lingering consent-banner node so it
  can't intercept catalog-footer checkbox clicks (the recurring flake).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:56:32 -07:00
10 changed files with 231 additions and 22 deletions
+26
View File
@@ -23,6 +23,32 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.52.1 — 2026-06-08
**Patch — collection-scoped entry-detail fetch fix (no operator action
required).**
Caught by the §9 deployed-environment E2E harness (0.52.0) running against
a PPE host whose content is cleanly isolated per collection:
- **Entry detail in a named collection 404'd ("Error: Not found") and its
metadata panel never rendered.** `RFCView` computed the collection id
from the route but called `getRFC(pid, slug)` without it, so an entry was
always fetched via the project's *default*-collection route
(`/api/projects/<pid>/rfcs/<slug>`). For an entry that lives only in a
named collection that route 404s. The bug was latent since the
multi-collection work — local/Tier-1 stacks masked it because the same
slug was also reachable through the default collection; a deployment with
per-collection-isolated content surfaces it. Fixed: all three `getRFC`
call sites in `RFCView` now pass the collection id (and the load effect
re-runs on collection change).
Test-only (not in the deployed artifact): the deployed-env E2E harness now
pre-records cookie consent via `addInitScript` so the bottom-fixed consent
banner can't intercept catalog row-select clicks on the slower deployed
edge, and `testing/seed-ppe.sh` fails loudly on any non-2xx Gitea response
(a swallowed 403 had let a missing-repo seed reach the deploy as a 502).
## 0.52.0 — 2026-06-07
**Minor — deployed-environment E2E harness (new opt-in test-auth surface;
+1 -1
View File
@@ -1 +1 @@
0.52.0
0.52.1
+40
View File
@@ -0,0 +1,40 @@
// Shared Playwright fixtures for the deployed-environment harness.
//
// Pre-record a cookie-consent choice via addInitScript so the §14.5
// cookie-consent banner NEVER renders. The banner is fixed to the bottom
// of the viewport and intercepts pointer events over the catalog footer
// (the row-select checkboxes SLICE-5 clicks). The previous approach —
// dismiss it after navigation (lib/ui.js dismissCookies) — raced the
// banner's render on the slower deployed edge (PPE): dismissCookies ran
// before the banner mounted, found nothing to remove, and the banner then
// appeared and swallowed the row clicks. Recording consent at
// document-start (before the app's scripts read `hasChosen()`) means the
// banner's `open` state initialises false and it never mounts — no race.
//
// Storage shape mirrors lib/consent.js (LS_KEY 'rfc-app.cookie-consent.v1';
// a non-null recorded_at == "the user has chosen"). Environment-agnostic:
// the init script runs on whatever origin the test navigates to (PPE or
// the Tier-1 localhost stack).
import { test as base, expect } from '@playwright/test'
const CONSENT = JSON.stringify({
essential: true,
analytics: false,
other: false,
recorded_at: '2000-01-01T00:00:00.000Z',
})
export const test = base.extend({
context: async ({ context }, use) => {
await context.addInitScript((value) => {
try {
window.localStorage.setItem('rfc-app.cookie-consent.v1', value)
} catch {
// localStorage unavailable — fall back to lib/ui.js dismissCookies.
}
}, CONSENT)
await use(context)
},
})
export { expect }
+9 -1
View File
@@ -4,7 +4,15 @@
export async function dismissCookies(page) {
const banner = page.locator('.cookie-consent-banner')
if (await banner.count()) {
// Click to persist the consent choice so it doesn't reappear on
// later navigation...
await page.getByRole('button', { name: 'Save choice' }).click().catch(() => {})
await banner.waitFor({ state: 'hidden' }).catch(() => {})
await banner.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
// ...then forcibly remove any node still in the DOM. The dismiss
// click occasionally doesn't land before a test clicks a catalog
// footer checkbox (flaky over the deployed edge), and a lingering
// fixed banner intercepts those pointer events. Removing the node
// makes the dismissal deterministic.
await banner.evaluate((el) => el.remove()).catch(() => {})
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test'
import { test, expect } from './lib/fixtures.js'
import { signIn, OWNER_EMAIL } from './lib/auth.js'
import { dismissCookies } from './lib/ui.js'
+16 -2
View File
@@ -1,9 +1,23 @@
import { defineConfig } from '@playwright/test'
// The metadata specs sign in, navigate, and (SLICE-4/5) write real commits,
// so a handful of steps are timing-sensitive: the cookie-consent banner's
// dismiss animation, first-render of the detail panel, and the round trip
// after a write. These flake intermittently on a busy local box and more so
// against a deployed host (network latency). `retries` makes the suite robust
// to that (and finally makes `trace: 'on-first-retry'` meaningful); the
// timeouts are bumped a notch for deployed runs over the public edge.
const DEPLOYED = !!process.env.E2E_TEST_AUTH_SECRET
export default defineConfig({
testDir: '.',
timeout: 30_000,
expect: { timeout: 10_000 },
timeout: DEPLOYED ? 60_000 : 45_000,
expect: { timeout: DEPLOYED ? 20_000 : 12_000 },
retries: 2,
// The metadata specs run in order against one seeded collection and write
// real commits (SLICE-4/5); parallel workers would race on shared state —
// and on a deployed host, on concurrent git pushes through the bot. Serialize.
workers: 1,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:8080',
trace: 'on-first-retry',
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.52.0",
"version": "0.52.1",
"type": "module",
"scripts": {
"dev": "vite",
+12 -5
View File
@@ -131,7 +131,14 @@ export default function RFCView({ viewer }) {
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
getRFC(pid, slug).then(entry => {
// §22.4a: an entry in a NAMED collection must be fetched collection-
// scoped. Omitting `cid` falls back to the project's DEFAULT-collection
// route (/api/projects/<pid>/rfcs/<slug>), which 404s for an entry that
// lives only in a named collection — surfacing as "Error: Not found" and
// a missing metadata panel. (Tier-1 masked this when the same slug was
// also reachable via the default collection; PPE's isolated content
// exposed it.)
getRFC(pid, slug, cid).then(entry => {
setEntry(entry)
// v0.15.0 — analytics: fire RFC Viewed once per slug load.
// We key on the slug param rather than the loaded entry so a
@@ -146,7 +153,7 @@ export default function RFCView({ viewer }) {
setSelectedModel(def || models?.[0]?.id || '')
})
.catch(() => {})
}, [slug, pid])
}, [slug, pid, cid])
// §22.4a SLICE-4: load the collection's metadata field schema for the
// detail panel. Independent of the entry load; the panel reads the entry's
@@ -194,12 +201,12 @@ export default function RFCView({ viewer }) {
setActionError(null)
try {
const res = await unretireRFC(slug)
getRFC(pid, slug).then(setEntry).catch(() => {})
getRFC(pid, slug, cid).then(setEntry).catch(() => {})
if (res?.state) navigate(entryPath(pid, slug))
} catch (err) {
setActionError(err.message)
}
}, [slug, navigate, pid])
}, [slug, navigate, pid, cid])
// Load main view + branch view whenever slug/branch changes.
useEffect(() => {
@@ -985,7 +992,7 @@ export default function RFCView({ viewer }) {
onCompleted={() => {
setShowGraduateDialog(false)
// The catalog row and the RFC view now reflect `active`.
getRFC(pid, slug).then(setEntry).catch(() => {})
getRFC(pid, slug, cid).then(setEntry).catch(() => {})
getRFCMain(slug).then(setMainView).catch(() => {})
}}
/>
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
# One-shot resume for the §9 PPE deployed-environment E2E stage.
#
# PRECONDITION: the operator has run the interactive Workspace reauth:
# gcloud auth login && gcloud auth application-default login
# (Only they can — the gcloud CLI creds expire under the Workspace session
# policy even when ADC is valid.)
#
# This script then runs the whole pipeline non-interactively:
# 1. read the bot token from Secret Manager (never echoed) and use it to
# create + seed the dedicated PPE registry + content repos;
# 2. ensure the E2E test-auth shared secret exists (generates one if not);
# 3. deploy rfc-app-ppe via flotilla-core (pins .rfc-app-version.ppe=0.52.0);
# 4. wait for /api/health to report the expected version, then for the
# reconciler to sync the seeded bdd collection into the cache;
# 5. run metadata.spec.js (SLICE-3/4/5) against the deployed PPE host.
#
# Idempotent: re-running re-seeds (RESEED=1 restores SLICE-4/5 preconditions),
# reuses the existing E2E secret, and redeploys.
REPO_ROOT="$HOME/git/wiggleverse.org/ben.stull/rfc-app"
FLOTILLA="$HOME/git/wiggleverse.org/wiggleverse/flotilla-core/.venv/bin/flotilla-core"
PPE_HOST="https://rfc-ppe.wiggleverse.org"
EXPECT_VERSION="$(cat "$REPO_ROOT/VERSION")"
BOT_SECRET_PROJECT="wiggleverse-ohm"
BOT_SECRET_ID="ohm-rfc-app-gitea-bot-token"
E2E_SECRET_PROJECT="rfc-app-ppe"
E2E_SECRET_ID="rfc-app-ppe-e2e-test-auth-secret"
E2E_EMAIL="e2e-owner@example.test"
export CLOUDSDK_ACTIVE_CONFIG_NAME="rfc-app-ppe"
echo "== 0. precheck gcloud reauth =="
if ! gcloud secrets list --project="$E2E_SECRET_PROJECT" --limit=1 >/dev/null 2>&1; then
echo "gcloud is not reauthed. Run: gcloud auth login && gcloud auth application-default login" >&2
exit 1
fi
echo "gcloud OK"
echo "== 1. create + seed PPE repos (Keychain admin token; never echoed) =="
# Seeding CREATES the two org repos (rfc-registry-ppe, rfc-app-ppe-content),
# which needs a write:organization-scoped token. The SM bot token is
# write:repository only (org create → 403), so use the operator's Keychain
# admin PAT (wgl-gitea-token-<host>, legacy fallback ohm-gitea-token). The
# token stays in the env var — never echoed (§6.3).
SEED_TOKEN="$(security find-generic-password -s "wgl-gitea-token-git.wiggleverse.org" -w 2>/dev/null \
|| security find-generic-password -s "ohm-gitea-token" -w 2>/dev/null)"
[ -n "$SEED_TOKEN" ] || { echo "no Keychain Gitea token found" >&2; exit 1; }
GITEA_TOKEN="$SEED_TOKEN" \
RESEED="${RESEED:-1}" \
bash "$REPO_ROOT/testing/seed-ppe.sh"
unset SEED_TOKEN GITEA_TOKEN
echo "== 2. ensure E2E test-auth secret exists =="
if gcloud secrets describe "$E2E_SECRET_ID" --project="$E2E_SECRET_PROJECT" >/dev/null 2>&1; then
echo "E2E secret already exists; ensuring binding"
"$FLOTILLA" secret bind rfc-app-ppe E2E_TEST_AUTH_SECRET "$E2E_SECRET_PROJECT/$E2E_SECRET_ID@latest"
else
echo "creating E2E secret (random, via stdin — bytes never echoed)"
# `printf %s "$(...)"` stores EXACTLY 64 hex bytes with NO trailing newline.
# A bare `openssl rand -hex 32 | ...` stores 65 bytes (the trailing \n),
# which then rode into the VM .env and made the server's secret differ from
# the runner's command-substitution-stripped value → /auth/test/login 404
# (compare_digest mismatch). Keep it newline-free.
printf '%s' "$(openssl rand -hex 32)" | "$FLOTILLA" secret set rfc-app-ppe E2E_TEST_AUTH_SECRET
fi
echo "== 3. deploy rfc-app-ppe =="
"$FLOTILLA" deploy rfc-app-ppe
echo "== 4a. verify /api/health reports $EXPECT_VERSION =="
ok=0
for _ in $(seq 1 24); do
body="$(curl -s "$PPE_HOST/api/health" || true)"
echo " health: $body"
if printf '%s' "$body" | grep -q "\"version\":\"$EXPECT_VERSION\""; then ok=1; break; fi
sleep 5
done
[ "$ok" = 1 ] || { echo "health never reported $EXPECT_VERSION" >&2; exit 1; }
echo "== 4b. wait for the seeded bdd collection to sync into the cache =="
ok=0
for _ in $(seq 1 40); do
body="$(curl -s "$PPE_HOST/api/projects/ohm/collections/bdd/rfcs" || true)"
n="$(printf '%s' "$body" | grep -o 'checkout-guest\|checkout-returning\|search-facets' | sort -u | wc -l | tr -d ' ')"
echo " synced entries: $n/3"
if [ "$n" = 3 ]; then ok=1; break; fi
sleep 6
done
[ "$ok" = 1 ] || { echo "bdd collection never synced 3 entries" >&2; exit 1; }
echo "== 5. run metadata.spec.js against PPE =="
E2E_SECRET="$(gcloud secrets versions access latest --secret="$E2E_SECRET_ID" --project="$E2E_SECRET_PROJECT")"
cd "$REPO_ROOT/e2e"
BASE_URL="$PPE_HOST" \
E2E_TEST_AUTH_SECRET="$E2E_SECRET" \
E2E_OWNER_EMAIL="$E2E_EMAIL" \
npx playwright test metadata.spec.js
echo "== DONE: PPE E2E complete =="
+25 -11
View File
@@ -44,6 +44,22 @@ RESEED="${RESEED:-0}"
api() { curl -s -H "Authorization: token $TOKEN" "$@"; }
# mutate <method> <url> <json> <ok_code> <label>
# Performs an authenticated write and FAILS LOUDLY on any non-<ok_code>
# response. `api` uses `curl -s` (no -f), so without this a 403/409/etc.
# returns exit 0 with an error JSON body — which once let a swallowed 403
# (org-repo create needs write:organization) sail past as "created…" and
# only surfaced as a 502 at deploy time. Never let an HTTP error be silent.
mutate() {
_m="$1"; _u="$2"; _d="$3"; _ok="$4"; _lbl="$5"
_resp=$(api -X "$_m" "$_u" -H 'Content-Type: application/json' -d "$_d" -w '\n%{http_code}')
_code=$(printf '%s' "$_resp" | tail -n1)
if [ "$_code" != "$_ok" ]; then
echo "seed-ppe: $_lbl FAILED (http $_code): $(printf '%s' "$_resp" | sed '$d' | head -c 300)" >&2
exit 1
fi
}
echo "seed-ppe: target $GITEA org=$ORG registry=$REGISTRY_REPO content=$CONTENT_REPO project=$PROJECT_ID"
ensure_repo() {
@@ -52,9 +68,9 @@ ensure_repo() {
return 0
fi
echo "seed-ppe: creating repo $ORG/$1 (private)"
api -X POST "$GITEA/api/v1/orgs/$ORG/repos" -H 'Content-Type: application/json' \
-d "{\"name\":\"$1\",\"auto_init\":true,\"default_branch\":\"main\",\"private\":true}" >/dev/null \
|| { echo "seed-ppe: failed to create $1" ; exit 1; }
mutate POST "$GITEA/api/v1/orgs/$ORG/repos" \
"{\"name\":\"$1\",\"auto_init\":true,\"default_branch\":\"main\",\"private\":true}" \
201 "create repo $ORG/$1 (org-repo create needs a write:organization token)"
}
# file_sha <repo> <path> -> prints the blob sha if the file exists, else empty
@@ -73,20 +89,18 @@ put_file() {
if [ -n "$_sha" ]; then
if [ "$_force" = "1" ]; then
echo "seed-ppe: updating $_repo/$_path"
api -X PUT "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
-H 'Content-Type: application/json' \
-d "{\"message\":\"reseed $_path\",\"content\":\"$_b64\",\"sha\":\"$_sha\",\"branch\":\"main\"}" >/dev/null \
|| echo "seed-ppe: update $_repo/$_path failed, continuing"
mutate PUT "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
"{\"message\":\"reseed $_path\",\"content\":\"$_b64\",\"sha\":\"$_sha\",\"branch\":\"main\"}" \
200 "update $_repo/$_path"
else
echo "seed-ppe: $_repo/$_path exists, leaving as-is"
fi
return 0
fi
echo "seed-ppe: creating $_repo/$_path"
api -X POST "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
-H 'Content-Type: application/json' \
-d "{\"message\":\"seed $_path\",\"content\":\"$_b64\",\"branch\":\"main\"}" >/dev/null \
|| { echo "seed-ppe: create $_repo/$_path failed" ; exit 1; }
mutate POST "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
"{\"message\":\"seed $_path\",\"content\":\"$_b64\",\"branch\":\"main\"}" \
201 "create $_repo/$_path"
}
ensure_repo "$REGISTRY_REPO"