diff --git a/rivet-cli/src/render/artifacts.rs b/rivet-cli/src/render/artifacts.rs index 1fd5eef..40ba287 100644 --- a/rivet-cli/src/render/artifacts.rs +++ b/rivet-cli/src/render/artifacts.rs @@ -44,6 +44,63 @@ use super::RenderResult; use super::helpers::badge_for_type; use crate::serve::components::ViewParams; +/// Wrap any `
` block inside an HTML fragment +/// (typically the output of `render_markdown` on an artifact description) +/// in the shared `.svg-viewer` shell with the standard zoom-fit / +/// fullscreen / popout toolbar. +/// +/// The dashboard renders mermaid diagrams from two surfaces: +/// +/// * The structured `diagram:` field — already wrapped in `.svg-viewer` +/// by `render_artifact_detail` below. +/// * Fenced ```mermaid blocks inside the artifact's `description` — +/// emitted by `rivet_core::markdown::render_markdown` as bare +/// `
`.
+///
+/// Without this wrapping the description-based diagrams missed the
+/// toolbar, breaking the cross-view diagram-viewer parity contract
+/// pinned by `tests/playwright/artifacts.spec.ts:70`.
+///
+/// The transform is a textual scan rather than a DOM rewrite to keep
+/// the render path dependency-free; pulldown-cmark always emits the
+/// open tag verbatim as `
` (see the
+/// `MERMAID_OPEN` sentinel in `rivet_core::markdown`), so a literal
+/// substring match is reliable.
+fn wrap_markdown_mermaid_in_svg_viewer(html: &str) -> String {
+    const NEEDLE: &str = "
";
+    if !html.contains(NEEDLE) {
+        return html.to_string();
+    }
+    const TOOLBAR: &str = "
\ +
\ + \ + \ + \ +
"; + let mut out = String::with_capacity(html.len() + 256); + let mut cursor = 0; + while let Some(start) = html[cursor..].find(NEEDLE) { + let abs_start = cursor + start; + out.push_str(&html[cursor..abs_start]); + // Find the matching `
` close. No nested `
` is
+        // possible inside a fenced code block, so the first `
` + // after the open is the right one. + if let Some(close_rel) = html[abs_start..].find("
") { + let close_end = abs_start + close_rel + "
".len(); + out.push_str(TOOLBAR); + out.push_str(&html[abs_start..close_end]); + out.push_str(""); // .svg-viewer + cursor = close_end; + } else { + // Unterminated — emit the rest as-is and stop. + out.push_str(&html[abs_start..]); + return out; + } + } + out.push_str(&html[cursor..]); + out +} + // ── Artifacts list ──────────────────────────────────────────────────────── pub(crate) fn render_artifacts_list(ctx: &RenderContext, params: &ViewParams) -> String { @@ -451,9 +508,15 @@ pub(crate) fn render_artifact_detail(ctx: &RenderContext, id: &str) -> RenderRes html_escape(&artifact.title) )); if let Some(desc) = &artifact.description { + // Render markdown, then wrap any `
` blocks in
+        // the same `.svg-viewer` toolbar shell used by the dedicated
+        // `diagram:` field below — so a mermaid diagram embedded in a
+        // description gets the same zoom-fit / fullscreen / popout
+        // controls as one supplied via the structured field. Pins the
+        // diagram-viewer parity contract (tests/playwright/artifacts.spec.ts:70).
+        let rendered = wrap_markdown_mermaid_in_svg_viewer(&render_markdown(desc));
         html.push_str(&format!(
-            "
Description
{}
", - render_markdown(desc) + "
Description
{rendered}
" )); } if let Some(status) = &artifact.status { @@ -804,3 +867,42 @@ fn find_source_ref(s: &str) -> Option { } None } + +// ── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // rivet: verifies REQ-007 (artifact diagram viewer parity) + #[test] + fn wraps_single_mermaid_block_in_svg_viewer() { + let html = "

intro

graph LR\nA-->B

outro

"; + let wrapped = wrap_markdown_mermaid_in_svg_viewer(html); + assert!(wrapped.contains("
")); + assert!(wrapped.contains("svg-viewer-toolbar")); + assert!(wrapped.contains("title=\"Fullscreen\"")); + // Pre block still in place. + assert!(wrapped.contains("
graph LR"));
+        // Surrounding paragraphs preserved.
+        assert!(wrapped.contains("

intro

")); + assert!(wrapped.contains("

outro

")); + } + + // rivet: verifies REQ-007 + #[test] + fn no_mermaid_means_no_change() { + let html = "

plain description with foo

"; + assert_eq!(wrap_markdown_mermaid_in_svg_viewer(html), html); + } + + // rivet: verifies REQ-007 + #[test] + fn wraps_multiple_mermaid_blocks() { + let html = "
A
mid
B
"; + let wrapped = wrap_markdown_mermaid_in_svg_viewer(html); + // Two viewer wrappers present. + assert_eq!(wrapped.matches("
").count(), 2); + assert_eq!(wrapped.matches("svg-viewer-toolbar").count(), 2); + } +} diff --git a/rivet-cli/src/serve/layout.rs b/rivet-cli/src/serve/layout.rs index 10c987d..14f1a9d 100644 --- a/rivet-cli/src/serve/layout.rs +++ b/rivet-cli/src/serve/layout.rs @@ -200,13 +200,18 @@ pub(crate) fn page_layout_with_variant( s }; // Variant selector: only rendered when the project has a feature model. + // + // The change handler is wired up in the variant-sync script (below) + // via `addEventListener('change', …)` instead of an inline `onchange` + // attribute. Inline handlers are blocked by some headless Chromium + // configs (e.g. Playwright's default page CSP) and can be silently + // skipped when the selectOption action mutates the DOM via the + // accessibility tree. The addEventListener route runs in every + // env we care about. let variant_selector_html = if state.variants.has_model() { let mut s = String::from( "/\ ` ends up nested inside `#cmd-k-results`, + // tripping Playwright's strict-mode locator and confusing keyboard + // navigation. Treat /search like the other API endpoints. if method == axum::http::Method::GET && !is_htmx && (path != "/" || is_print || is_embed) && !path.starts_with("/api/") && !path.starts_with("/mcp") && !path.starts_with("/oembed") + && !path.starts_with("/search") && !path.starts_with("/assets/") && !path.starts_with("/wasm/") && !path.starts_with("/source-raw/") diff --git a/rivet-cli/tests/serve_integration.rs b/rivet-cli/tests/serve_integration.rs index 7e438c8..95c9368 100644 --- a/rivet-cli/tests/serve_integration.rs +++ b/rivet-cli/tests/serve_integration.rs @@ -1106,3 +1106,64 @@ fn stats_page_shows_variant_banner_when_scoped() { child.kill().ok(); child.wait().ok(); } + +// ── /embed/* path rewriting (REQ-007 + tests/playwright/api.spec.ts:291) ── + +#[test] +fn embed_artifact_returns_200_with_embed_layout() { + // Regression: the wrap_full_page middleware strips /embed and routes + // to /artifacts/{id} so the dashboard can iframe-embed an artifact + // without registering duplicate routes. A previous URI-rewriting + // bug (round-tripping `Uri::into_parts` / `from_parts`) left the + // inner router with an empty matched path, returning a wrapped 404 + // — exactly the symptom Playwright's api.spec.ts:291 catches. + let (mut child, port) = start_server(); + let (status, body, _) = fetch(port, "/embed/artifacts/REQ-001", false); + assert_eq!( + status, 200, + "/embed/artifacts/REQ-001 must route through to artifact_detail" + ); + // embed_layout (no nav, no .shell) — distinct from page_layout. + assert!( + !body.contains("class=\"shell\""), + "/embed/* must not render the sidebar shell" + ); + assert!( + !body.contains("Main navigation"), + "/embed/* must not render the main nav" + ); + // Artifact body still renders (REQ-001 always exists in the test fixture). + assert!( + body.contains("REQ-001"), + "embed body must contain the artifact ID, got body of length {}", + body.len() + ); + // htmx is loaded so the embedded view stays interactive. + assert!( + body.contains("htmx"), + "embed body must include htmx (script tag)" + ); + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn embed_unknown_artifact_returns_200_with_not_found_body() { + // Unknown artifact under /embed should still go through the embed + // layout — render_artifact_detail returns a 200 with a "Not Found" + // body, which the embed wrap preserves. Exercises the same + // middleware-strip path as the happy case. + let (mut child, port) = start_server(); + let (status, body, _) = fetch(port, "/embed/artifacts/DOES-NOT-EXIST", false); + assert_eq!(status, 200); + assert!( + body.contains("Not Found"), + "embed body for unknown artifact should include 'Not Found'" + ); + assert!( + !body.contains("Main navigation"), + "/embed/* must not render the main nav even for not-found" + ); + child.kill().ok(); + child.wait().ok(); +} diff --git a/tests/playwright/diagram-viewer.spec.ts b/tests/playwright/diagram-viewer.spec.ts index 1467eb6..b6422bf 100644 --- a/tests/playwright/diagram-viewer.spec.ts +++ b/tests/playwright/diagram-viewer.spec.ts @@ -19,8 +19,11 @@ const VIEWER_PAGES = [ { name: "graph", url: "/graph?limit=2000" }, // Doc linkage view. { name: "doc-linkage", url: "/doc-linkage" }, - // Help / schema page renders the schema-linkage mermaid diagram. - { name: "schema-linkage", url: "/help/schema" }, + // /help renders the schema-linkage mermaid diagram (a Schema Linkage + // card showing artifact-type relationships in the dashboard's design + // language). The diagram lives on the help index, not /help/schema — + // /help/schema is the type-list table. + { name: "schema-linkage", url: "/help" }, ]; for (const page of VIEWER_PAGES) { diff --git a/tests/playwright/filter-sort.spec.ts b/tests/playwright/filter-sort.spec.ts index e8cee50..6eddd70 100644 --- a/tests/playwright/filter-sort.spec.ts +++ b/tests/playwright/filter-sort.spec.ts @@ -236,8 +236,14 @@ test.describe("Artifacts Filter/Sort/Pagination", () => { // Verify the input is wired for URL push-on-type. await expect(searchInput).toHaveAttribute("hx-push-url", "true"); - // Type a query — HTMX fires a debounced hx-get and pushes the URL. - await searchInput.fill("OSLC"); + // Type a query — HTMX fires a debounced hx-get on `keyup changed` + // (see rivet-cli/src/serve/components.rs ~line 216), so we need to + // generate real keypress events. Playwright's `fill()` sets the + // value via JS and only fires `input`, not `keyup`, so HTMX would + // never see the trigger. `pressSequentially` types one char at a + // time, dispatching keydown/keypress/keyup for each. + await searchInput.click(); + await searchInput.pressSequentially("OSLC"); await waitForHtmx(page); // URL must now carry ?q=OSLC. diff --git a/tests/playwright/rivet-delta.spec.ts b/tests/playwright/rivet-delta.spec.ts index f301e21..e04678f 100644 --- a/tests/playwright/rivet-delta.spec.ts +++ b/tests/playwright/rivet-delta.spec.ts @@ -190,6 +190,11 @@ test.describe("rivet-delta PR-comment output", () => { await expect( page.locator("h2", { hasText: "Rivet artifact delta" }), ).toBeVisible(); + // REQ-NEW-1 lives inside the collapsed
Added + // block (rendered by scripts/diff-to-markdown.mjs:181-188), so it is + // attached to the DOM but not "visible" until that
is + // expanded. Open it before checking visibility. + await page.locator("details summary", { hasText: "Added" }).click(); await expect( page .locator("code:not(.language-mermaid)", { hasText: "REQ-NEW-1" })