Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afa8d26378 | |||
| 948ee88160 | |||
| 7bcf784d06 | |||
| 8e207a60e6 | |||
| fbaa975b5c | |||
| ba37da927a | |||
| 9c8035bdbd | |||
| fd123da6a3 |
@@ -23,6 +23,57 @@ skip versions are the composition of each intervening adjacent
|
|||||||
release's steps in order — no A-to-B path is pre-computed beyond
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
that.
|
||||||
|
|
||||||
|
## 0.52.3 — 2026-06-09
|
||||||
|
|
||||||
|
**Patch — unmatched routes render a 404 instead of a blank page or a
|
||||||
|
silent redirect home (no operator action required).**
|
||||||
|
|
||||||
|
- **The top-level catch-all redirected any unknown path to `/`**, and the
|
||||||
|
nested `/admin/*`, `/p/:projectId/*`, and `/docs/*` route groups had no
|
||||||
|
catch-all (so an invalid subpath rendered an empty pane). Added a shared
|
||||||
|
`NotFound` 404 page and wired it into all four route groups, so a bad or
|
||||||
|
typo'd URL — including a stale `/admin/users/allowlist/graduation`-style
|
||||||
|
path — now shows a clear "page not found" with a link back to the catalog.
|
||||||
|
(Client-side 404 UI; the SPA still serves over HTTP 200, as before.)
|
||||||
|
|
||||||
|
## 0.52.2 — 2026-06-09
|
||||||
|
|
||||||
|
**Patch — admin sidebar nav links no longer accumulate URL segments (no
|
||||||
|
operator action required).**
|
||||||
|
|
||||||
|
- **The `/admin` left-rail links were relative**, so under the `/admin/*`
|
||||||
|
nested route each click resolved against the current URL and *appended* a
|
||||||
|
segment — e.g. clicking Users → Allowlist → Graduation produced
|
||||||
|
`/admin/users/allowlist/graduation` instead of `/admin/graduation`. Fixed:
|
||||||
|
the rail `NavLink`s now use absolute targets (`/admin/<tab>`) with `end`
|
||||||
|
for exact active-state matching.
|
||||||
|
|
||||||
|
## 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
|
## 0.52.0 — 2026-06-07
|
||||||
|
|
||||||
**Minor — deployed-environment E2E harness (new opt-in test-auth surface;
|
**Minor — deployed-environment E2E harness (new opt-in test-auth surface;
|
||||||
|
|||||||
@@ -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
@@ -4,7 +4,15 @@
|
|||||||
export async function dismissCookies(page) {
|
export async function dismissCookies(page) {
|
||||||
const banner = page.locator('.cookie-consent-banner')
|
const banner = page.locator('.cookie-consent-banner')
|
||||||
if (await banner.count()) {
|
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 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(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -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 { signIn, OWNER_EMAIL } from './lib/auth.js'
|
||||||
import { dismissCookies } from './lib/ui.js'
|
import { dismissCookies } from './lib/ui.js'
|
||||||
|
|
||||||
@@ -43,15 +43,22 @@ test('SLICE-4: edit one entry\'s priority from the detail panel', async ({ page
|
|||||||
|
|
||||||
const panel = page.locator('.metadata-fields-panel')
|
const panel = page.locator('.metadata-fields-panel')
|
||||||
await expect(panel).toBeVisible()
|
await expect(panel).toBeVisible()
|
||||||
// Seeded at P1; change to P2 and save (direct sidecar commit, D7).
|
const select = panel.locator('#mf-priority')
|
||||||
await expect(panel.locator('#mf-priority')).toHaveValue('P1')
|
// Value-agnostic so the test is idempotent across retries and re-runs: a
|
||||||
await panel.locator('#mf-priority').selectOption('P2')
|
// prior (committed) edit may have already moved it off the P1 seed. Pick a
|
||||||
|
// target distinct from the current value so Save is a real change.
|
||||||
|
const current = await select.inputValue()
|
||||||
|
const target = current === 'P2' ? 'P1' : 'P2'
|
||||||
|
await select.selectOption(target)
|
||||||
await panel.getByRole('button', { name: 'Save' }).click()
|
await panel.getByRole('button', { name: 'Save' }).click()
|
||||||
await expect(panel.getByText('Saved')).toBeVisible()
|
// The save is a real git sidecar commit via the bot (D7); over the deployed
|
||||||
|
// edge the round trip can take tens of seconds — wait generously for the
|
||||||
|
// confirmation rather than the default expect timeout.
|
||||||
|
await expect(panel.getByText('Saved')).toBeVisible({ timeout: 60_000 })
|
||||||
|
|
||||||
// Persisted across a reload (read back from the committed sidecar).
|
// Persisted across a reload (read back from the committed sidecar).
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.locator('.metadata-fields-panel #mf-priority')).toHaveValue('P2')
|
await expect(page.locator('.metadata-fields-panel #mf-priority')).toHaveValue(target)
|
||||||
})
|
})
|
||||||
|
|
||||||
// SLICE-5 (PUC-2) — multi-select + bulk action bar, one commit (authed).
|
// SLICE-5 (PUC-2) — multi-select + bulk action bar, one commit (authed).
|
||||||
@@ -71,8 +78,10 @@ test('SLICE-5: bulk-set priority on multiple selected entries', async ({ page })
|
|||||||
await expect(bar.getByText('2 selected')).toBeVisible()
|
await expect(bar.getByText('2 selected')).toBeVisible()
|
||||||
|
|
||||||
// Set priority P1 across both → one commit; a toast reports the result.
|
// Set priority P1 across both → one commit; a toast reports the result.
|
||||||
|
// Like SLICE-4, the bulk write is a real git commit via the bot; allow the
|
||||||
|
// deployed-edge round trip generous time before the result toast appears.
|
||||||
await bar.getByLabel('Set Priority').selectOption('P1')
|
await bar.getByLabel('Set Priority').selectOption('P1')
|
||||||
await expect(page.getByText(/2 updated/)).toBeVisible()
|
await expect(page.getByText(/2 updated/)).toBeVisible({ timeout: 60_000 })
|
||||||
|
|
||||||
// The change is reflected server-side: the Priority P1 facet now counts the
|
// The change is reflected server-side: the Priority P1 facet now counts the
|
||||||
// two newly-updated entries plus the pre-existing P1 (checkout-returning was
|
// two newly-updated entries plus the pre-existing P1 (checkout-returning was
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import { defineConfig } from '@playwright/test'
|
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({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
timeout: 30_000,
|
timeout: DEPLOYED ? 60_000 : 45_000,
|
||||||
expect: { timeout: 10_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: {
|
use: {
|
||||||
baseURL: process.env.BASE_URL || 'http://localhost:8080',
|
baseURL: process.env.BASE_URL || 'http://localhost:8080',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.52.0",
|
"version": "0.52.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2687,3 +2687,19 @@ select:focus-visible,
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 404 / not-found page (client-side, for unmatched routes) */
|
||||||
|
.not-found {
|
||||||
|
max-width: 32rem;
|
||||||
|
margin: 4rem auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.not-found-code {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--c-gray-900, #111);
|
||||||
|
}
|
||||||
|
.not-found-message { color: var(--c-gray-700, #444); margin: 0 0 1.25rem; }
|
||||||
|
.not-found-actions a { color: var(--c-gray-900, #111); text-decoration: underline; }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { brandTitle } from './lib/brand'
|
|||||||
import { entryPath, proposalPath, DEFAULT_COLLECTION } from './lib/entryPaths'
|
import { entryPath, proposalPath, DEFAULT_COLLECTION } from './lib/entryPaths'
|
||||||
import { useDeployment } from './context/DeploymentProvider'
|
import { useDeployment } from './context/DeploymentProvider'
|
||||||
import ProjectLayout from './components/ProjectLayout.jsx'
|
import ProjectLayout from './components/ProjectLayout.jsx'
|
||||||
|
import NotFound from './components/NotFound.jsx'
|
||||||
import Directory from './components/Directory.jsx'
|
import Directory from './components/Directory.jsx'
|
||||||
import ProjectSwitcher from './components/ProjectSwitcher.jsx'
|
import ProjectSwitcher from './components/ProjectSwitcher.jsx'
|
||||||
import Catalog from './components/Catalog.jsx'
|
import Catalog from './components/Catalog.jsx'
|
||||||
@@ -381,13 +382,14 @@ export default function App() {
|
|||||||
<Route path="c/:collectionId/e/:slug" element={<RFCView viewer={viewer} />} />
|
<Route path="c/:collectionId/e/:slug" element={<RFCView viewer={viewer} />} />
|
||||||
<Route path="c/:collectionId/e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
<Route path="c/:collectionId/e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||||||
<Route path="c/:collectionId/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
<Route path="c/:collectionId/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</ProjectLayout>
|
</ProjectLayout>
|
||||||
} />
|
} />
|
||||||
{/* Any other path (incl. the retired bare-slug corpus URLs that
|
{/* Any unmatched path is a real 404 — surface it rather than
|
||||||
somehow reach the SPA) lands on the deployment landing. */}
|
silently bouncing home, so a bad/typo'd URL is visible. */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
{(proposeOpen || proposeParam != null) && viewer && (
|
{(proposeOpen || proposeParam != null) && viewer && (
|
||||||
@@ -507,6 +509,7 @@ function DocsWithSidebar({ viewer }) {
|
|||||||
client-side redirect to the first configured spec. */}
|
client-side redirect to the first configured spec. */}
|
||||||
<Route path="specs" element={<DocsSpecsIndex />} />
|
<Route path="specs" element={<DocsSpecsIndex />} />
|
||||||
<Route path="specs/:name" element={<DocsSpec />} />
|
<Route path="specs/:name" element={<DocsSpec />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
import { EVENTS, track } from '../lib/analytics.js'
|
import { EVENTS, track } from '../lib/analytics.js'
|
||||||
import { entryPath, useProjectId } from '../lib/entryPaths'
|
import { entryPath, useProjectId } from '../lib/entryPaths'
|
||||||
|
import NotFound from './NotFound.jsx'
|
||||||
|
|
||||||
// v0.17.0 — roadmap item #16. The max length the backend enforces
|
// v0.17.0 — roadmap item #16. The max length the backend enforces
|
||||||
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
|
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
|
||||||
@@ -59,7 +60,8 @@ export default function Admin({ viewer }) {
|
|||||||
{tabs.map(t => (
|
{tabs.map(t => (
|
||||||
<li key={t.path}>
|
<li key={t.path}>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={t.path}
|
to={`/admin/${t.path}`}
|
||||||
|
end
|
||||||
className={({ isActive }) => `admin-rail-link ${isActive ? 'active' : ''}`}
|
className={({ isActive }) => `admin-rail-link ${isActive ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -81,6 +83,7 @@ export default function Admin({ viewer }) {
|
|||||||
{isSiteOwner && <Route path="retired" element={<RetiredTab />} />}
|
{isSiteOwner && <Route path="retired" element={<RetiredTab />} />}
|
||||||
<Route path="audit" element={<AuditTab />} />
|
<Route path="audit" element={<AuditTab />} />
|
||||||
<Route path="permissions" element={<PermissionsTab />} />
|
<Route path="permissions" element={<PermissionsTab />} />
|
||||||
|
<Route path="*" element={<NotFound message="That admin page doesn't exist." />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Client-side 404 for routes that don't match. An SPA serves index.html
|
||||||
|
// for every non-API path (HTTP 200), so an unknown URL would otherwise
|
||||||
|
// render blank — or, worse, silently bounce home. This surfaces a clear
|
||||||
|
// "page not found" instead. Used by the top-level router and by the
|
||||||
|
// nested /admin, /p/:projectId, and /docs route groups so an invalid
|
||||||
|
// subpath (e.g. /admin/users/allowlist/graduation) lands here too.
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function NotFound({ message }) {
|
||||||
|
return (
|
||||||
|
<div className="not-found" role="alert">
|
||||||
|
<h1 className="not-found-code">404</h1>
|
||||||
|
<p className="not-found-message">
|
||||||
|
{message || "We couldn't find that page."}
|
||||||
|
</p>
|
||||||
|
<p className="not-found-actions">
|
||||||
|
<Link to="/">Return to the catalog</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -131,7 +131,14 @@ export default function RFCView({ viewer }) {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
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)
|
setEntry(entry)
|
||||||
// v0.15.0 — analytics: fire RFC Viewed once per slug load.
|
// v0.15.0 — analytics: fire RFC Viewed once per slug load.
|
||||||
// We key on the slug param rather than the loaded entry so a
|
// 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 || '')
|
setSelectedModel(def || models?.[0]?.id || '')
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [slug, pid])
|
}, [slug, pid, cid])
|
||||||
|
|
||||||
// §22.4a SLICE-4: load the collection's metadata field schema for the
|
// §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
|
// detail panel. Independent of the entry load; the panel reads the entry's
|
||||||
@@ -194,12 +201,12 @@ export default function RFCView({ viewer }) {
|
|||||||
setActionError(null)
|
setActionError(null)
|
||||||
try {
|
try {
|
||||||
const res = await unretireRFC(slug)
|
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))
|
if (res?.state) navigate(entryPath(pid, slug))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(err.message)
|
setActionError(err.message)
|
||||||
}
|
}
|
||||||
}, [slug, navigate, pid])
|
}, [slug, navigate, pid, cid])
|
||||||
|
|
||||||
// Load main view + branch view whenever slug/branch changes.
|
// Load main view + branch view whenever slug/branch changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -985,7 +992,7 @@ export default function RFCView({ viewer }) {
|
|||||||
onCompleted={() => {
|
onCompleted={() => {
|
||||||
setShowGraduateDialog(false)
|
setShowGraduateDialog(false)
|
||||||
// The catalog row and the RFC view now reflect `active`.
|
// 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(() => {})
|
getRFCMain(slug).then(setMainView).catch(() => {})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Executable
+100
@@ -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 =="
|
||||||
+49
-11
@@ -44,6 +44,22 @@ RESEED="${RESEED:-0}"
|
|||||||
|
|
||||||
api() { curl -s -H "Authorization: token $TOKEN" "$@"; }
|
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"
|
echo "seed-ppe: target $GITEA org=$ORG registry=$REGISTRY_REPO content=$CONTENT_REPO project=$PROJECT_ID"
|
||||||
|
|
||||||
ensure_repo() {
|
ensure_repo() {
|
||||||
@@ -52,9 +68,9 @@ ensure_repo() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo "seed-ppe: creating repo $ORG/$1 (private)"
|
echo "seed-ppe: creating repo $ORG/$1 (private)"
|
||||||
api -X POST "$GITEA/api/v1/orgs/$ORG/repos" -H 'Content-Type: application/json' \
|
mutate POST "$GITEA/api/v1/orgs/$ORG/repos" \
|
||||||
-d "{\"name\":\"$1\",\"auto_init\":true,\"default_branch\":\"main\",\"private\":true}" >/dev/null \
|
"{\"name\":\"$1\",\"auto_init\":true,\"default_branch\":\"main\",\"private\":true}" \
|
||||||
|| { echo "seed-ppe: failed to create $1" ; exit 1; }
|
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
|
# file_sha <repo> <path> -> prints the blob sha if the file exists, else empty
|
||||||
@@ -73,20 +89,29 @@ put_file() {
|
|||||||
if [ -n "$_sha" ]; then
|
if [ -n "$_sha" ]; then
|
||||||
if [ "$_force" = "1" ]; then
|
if [ "$_force" = "1" ]; then
|
||||||
echo "seed-ppe: updating $_repo/$_path"
|
echo "seed-ppe: updating $_repo/$_path"
|
||||||
api -X PUT "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
|
mutate PUT "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
|
||||||
-H 'Content-Type: application/json' \
|
"{\"message\":\"reseed $_path\",\"content\":\"$_b64\",\"sha\":\"$_sha\",\"branch\":\"main\"}" \
|
||||||
-d "{\"message\":\"reseed $_path\",\"content\":\"$_b64\",\"sha\":\"$_sha\",\"branch\":\"main\"}" >/dev/null \
|
200 "update $_repo/$_path"
|
||||||
|| echo "seed-ppe: update $_repo/$_path failed, continuing"
|
|
||||||
else
|
else
|
||||||
echo "seed-ppe: $_repo/$_path exists, leaving as-is"
|
echo "seed-ppe: $_repo/$_path exists, leaving as-is"
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo "seed-ppe: creating $_repo/$_path"
|
echo "seed-ppe: creating $_repo/$_path"
|
||||||
api -X POST "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
|
mutate POST "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
|
||||||
-H 'Content-Type: application/json' \
|
"{\"message\":\"seed $_path\",\"content\":\"$_b64\",\"branch\":\"main\"}" \
|
||||||
-d "{\"message\":\"seed $_path\",\"content\":\"$_b64\",\"branch\":\"main\"}" >/dev/null \
|
201 "create $_repo/$_path"
|
||||||
|| { echo "seed-ppe: create $_repo/$_path failed" ; exit 1; }
|
}
|
||||||
|
|
||||||
|
# delete_file <repo> <path> — remove the file if it exists (no-op if absent).
|
||||||
|
delete_file() {
|
||||||
|
_repo="$1"; _path="$2"
|
||||||
|
_sha=$(file_sha "$_repo" "$_path")
|
||||||
|
[ -n "$_sha" ] || { echo "seed-ppe: $_repo/$_path absent, nothing to delete"; return 0; }
|
||||||
|
echo "seed-ppe: deleting $_repo/$_path"
|
||||||
|
mutate DELETE "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
|
||||||
|
"{\"message\":\"reseed: drop sidecar $_path\",\"sha\":\"$_sha\",\"branch\":\"main\"}" \
|
||||||
|
200 "delete $_repo/$_path"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_repo "$REGISTRY_REPO"
|
ensure_repo "$REGISTRY_REPO"
|
||||||
@@ -168,4 +193,17 @@ tags: [search]
|
|||||||
Faceted search scenario.
|
Faceted search scenario.
|
||||||
" "$RESEED"
|
" "$RESEED"
|
||||||
|
|
||||||
|
# RESEED also drops any metadata sidecars (<slug>.meta.yaml) left by prior
|
||||||
|
# SLICE-4/5 edits. A sidecar takes precedence over the .md frontmatter
|
||||||
|
# (dual-read, §22.4a), so without this a re-run inherits the edited
|
||||||
|
# priorities (everything ends up P1) and the faceted preconditions drift —
|
||||||
|
# SLICE-3 expects P0 count = 2. Dropping the sidecars restores the
|
||||||
|
# frontmatter as the source of truth: checkout-guest=P0, search-facets=P0,
|
||||||
|
# checkout-returning=P1.
|
||||||
|
if [ "$RESEED" = "1" ]; then
|
||||||
|
for _slug in checkout-guest checkout-returning search-facets; do
|
||||||
|
delete_file "$CONTENT_REPO" "bdd/rfcs/$_slug.meta.yaml"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "seed-ppe: done. Set REGISTRY_REPO=$REGISTRY_REPO on rfc-app-ppe and redeploy."
|
echo "seed-ppe: done. Set REGISTRY_REPO=$REGISTRY_REPO on rfc-app-ppe and redeploy."
|
||||||
|
|||||||
Reference in New Issue
Block a user