.
+ await expect(page.locator("main#content")).toBeVisible();
+ await expect(page.locator("main#content")).toContainText("REQ-001");
+ });
+});
+
+test.describe("Rendering invariants — render-shape contracts", () => {
+ test("mermaid in artifact `description` renders as ", async ({
+ page,
+ }) => {
+ // ARCH-CORE-001 has a fenced ```mermaid block in its `description` (see
+ // artifacts/architecture.yaml). The markdown renderer in
+ // rivet-core/src/markdown.rs converts these to
+ // so the dashboard's mermaid.js loader picks them up.
+ //
+ // We pin TWO things at once:
+ // 1. The fenced block IS recognised and emitted as .
+ // 2. Description-mermaid is currently NOT wrapped in .svg-viewer
+ // (only the dedicated `diagram:` field is). This asymmetry is a
+ // known UX gap; if it changes, this assertion forces the change
+ // to be intentional.
+ await page.goto("/artifacts/ARCH-CORE-001");
+ await waitForHtmx(page);
+
+ // The description is in a .
+ const desc = page.locator("dd.artifact-desc");
+ await expect(desc).toBeVisible();
+
+ // Inside that description, mermaid block was emitted as .
+ const mermaidPre = desc.locator("pre.mermaid");
+ await expect(mermaidPre).toBeVisible();
+ // Body should contain the diagram source so mermaid.js can render it.
+ await expect(mermaidPre).toContainText("flowchart");
+
+ // Pinning the current asymmetry: the description-embedded mermaid is
+ // NOT inside an .svg-viewer wrapper. (Only the top-level `diagram:`
+ // field gets one — see render/artifacts.rs:489.)
+ const wrappedInViewer = await desc
+ .locator(".svg-viewer pre.mermaid")
+ .count();
+ expect(wrappedInViewer).toBe(0);
+ });
+});
+
+test.describe("Rendering invariants — variant scoping coverage", () => {
+ test("/graph?variant=minimal-ci is silently UNSCOPED (graph_view ignores variant)", async ({
+ page,
+ }) => {
+ // graph_view in rivet-cli/src/serve/views.rs uses GraphParams (not
+ // ViewParams) and has no `variant` field. The query param is silently
+ // dropped. This means /graph?variant=... renders the FULL graph, not
+ // a variant-scoped subgraph.
+ //
+ // This is currently architecturally intentional (graph layout is
+ // expensive enough that variant scoping was deferred) but it's surprising
+ // for users coming from /artifacts?variant=... which IS scoped. Pin the
+ // current behavior so a future variant-scoping addition is gated.
+ const resp = await page.goto("/graph?variant=minimal-ci&types=requirement");
+ expect(resp?.status()).toBe(200);
+ // Page renders normally.
+ await expect(page.locator("h2")).toContainText("Traceability Graph", {
+ timeout: 30_000,
+ });
+ // The variant banner from layout reflects the URL's variant param.
+ // (The layout ALWAYS shows the banner when ?variant= is present, even
+ // when the handler ignores it — this is the surprising part to pin.)
+ await expect(page.locator(".variant-banner")).toBeVisible();
+ });
+});
+
+test.describe("Rendering invariants — not-found status conventions", () => {
+ test("/artifacts/UNKNOWN-ID returns 200 with 'Not Found' body (not 404)", async ({
+ page,
+ }) => {
+ // artifact_detail in rivet-cli/src/serve/views.rs always returns
+ // Html(...).into_response() — i.e. status 200 — even when the artifact
+ // doesn't exist. The render layer just emits "Not Found
".
+ //
+ // This is consistent with /externals/ (already pinned at
+ // externals.spec.ts:80) but inconsistent with the gut expectation of
+ // 404. Pin the current behavior so any future move to proper 404 is a
+ // conscious decision.
+ const resp = await page.goto("/artifacts/DEFINITELY-DOES-NOT-EXIST-ZZZ");
+ expect(resp?.status()).toBe(200);
+ await expect(page.locator("body")).toContainText("Not Found");
+ // The layout still wraps it (nav present).
+ await expect(page.locator("nav[role='navigation']")).toBeVisible();
+ });
+});
+
+test.describe("Rendering invariants — search fragment shape", () => {
+ test("/search with empty query returns the cmd-k empty-state fragment", async ({
+ page,
+ }) => {
+ // The search handler returns a FRAGMENT (no shell when accessed
+ // via HTMX) but routes.spec.ts only smoke-tests with ?q=OSLC. The
+ // empty-query branch (line 56-60 of render/search.rs) emits a specific
+ // .cmd-k-empty placeholder. Pin its shape so the cmd-k UI keeps working.
+ //
+ // Direct browser GET goes through wrap_full_page so we get the layout
+ // wrapping; the fragment lives inside main#content.
+ const resp = await page.goto("/search");
+ expect(resp?.status()).toBe(200);
+ const empty = page.locator(".cmd-k-empty").first();
+ await expect(empty).toBeVisible();
+ await expect(empty).toContainText(/Type to search/i);
+ });
+
+ test("/search?q=zzznonexistentzzz emits empty-results fragment", async ({
+ page,
+ }) => {
+ // Pins the no-results branch (line 189-194 of render/search.rs).
+ const resp = await page.goto("/search?q=zzznonexistentzzz");
+ expect(resp?.status()).toBe(200);
+ const empty = page.locator(".cmd-k-empty").first();
+ await expect(empty).toBeVisible();
+ await expect(empty).toContainText(/No results/i);
+ });
+});