Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 131 additions & 30 deletions rivet-cli/src/serve/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,17 @@ pub(crate) async fn stats_view(
// ── Externals ────────────────────────────────────────────────────────────

/// GET /externals — list all configured external projects.
pub(crate) async fn externals_list(State(state): State<SharedState>) -> Html<String> {
///
/// Variant scoping is **deliberately ignored** here: external projects are
/// loaded from sibling repos and do not participate in this project's
/// feature-model bindings, so a variant filter has no semantic meaning for
/// the externals overview. The `variant` param is accepted (so the layout
/// banner stays consistent and bookmarked URLs degrade gracefully) but does
/// not change which externals are listed.
pub(crate) async fn externals_list(
State(state): State<SharedState>,
Query(_params): Query<ViewParams>,
) -> Html<String> {
let state = state.read().await;
let ctx = state.as_render_context();
Html(crate::render::externals::render_externals_list(&ctx))
Expand Down Expand Up @@ -160,6 +170,12 @@ pub(crate) struct GraphParams {
focus: Option<String>,
/// Optional override of the node render budget. Capped in the renderer.
limit: Option<usize>,
/// Active variant scope (by name). When set, the graph is built
/// against a store filtered to artifacts bound to the variant's
/// effective features. Variant scoping reduces the node count,
/// which materially helps graph layout performance on large
/// projects.
variant: Option<String>,
}

fn default_depth() -> usize {
Expand All @@ -170,17 +186,24 @@ fn default_depth() -> usize {
pub(crate) async fn graph_view(
State(state): State<SharedState>,
Query(params): Query<GraphParams>,
) -> Html<String> {
) -> Response {
let state = state.read().await;
let ctx = state.as_render_context();
let scope = match try_build_scope(&state, &params.variant) {
Ok(s) => s,
Err(resp) => return resp,
};
let rparams = crate::render::graph::GraphParams {
types: params.types,
link_types: params.link_types,
depth: params.depth,
focus: params.focus,
limit: params.limit,
};
Html(crate::render::graph::render_graph_view(&ctx, &rparams))
let html = match scope.as_ref() {
Some(s) => crate::render::graph::render_graph_view(&s.render_context(&state), &rparams),
None => crate::render::graph::render_graph_view(&state.as_render_context(), &rparams),
};
Html(html).into_response()
}

// ── Ego graph for a single artifact ──────────────────────────────────────
Expand Down Expand Up @@ -307,10 +330,20 @@ pub(crate) async fn coverage_view(

// ── Documents ────────────────────────────────────────────────────────────

pub(crate) async fn documents_list(State(state): State<SharedState>) -> Html<String> {
pub(crate) async fn documents_list(
State(state): State<SharedState>,
Query(params): Query<ViewParams>,
) -> Response {
let state = state.read().await;
let ctx = state.as_render_context();
Html(crate::render::documents::render_documents_list(&ctx))
let scope = match try_build_scope(&state, &params.variant) {
Ok(s) => s,
Err(resp) => return resp,
};
let html = match scope.as_ref() {
Some(s) => crate::render::documents::render_documents_list(&s.render_context(&state)),
None => crate::render::documents::render_documents_list(&state.as_render_context()),
};
Html(html).into_response()
}

pub(crate) async fn document_detail(
Expand All @@ -328,26 +361,49 @@ pub(crate) async fn document_detail(
#[derive(Debug, serde::Deserialize)]
pub(crate) struct SearchParams {
q: Option<String>,
/// Active variant scope (by name). When set, search is restricted
/// to artifacts bound to the variant's effective features.
variant: Option<String>,
}

pub(crate) async fn search_view(
State(state): State<SharedState>,
Query(params): Query<SearchParams>,
) -> Html<String> {
) -> Response {
let state = state.read().await;
let ctx = state.as_render_context();
Html(crate::render::search::render_search_view(
&ctx,
params.q.as_deref(),
))
let scope = match try_build_scope(&state, &params.variant) {
Ok(s) => s,
Err(resp) => return resp,
};
let html = match scope.as_ref() {
Some(s) => crate::render::search::render_search_view(
&s.render_context(&state),
params.q.as_deref(),
),
None => crate::render::search::render_search_view(
&state.as_render_context(),
params.q.as_deref(),
),
};
Html(html).into_response()
}

// ── Verification ─────────────────────────────────────────────────────────

pub(crate) async fn verification_view(State(state): State<SharedState>) -> Html<String> {
pub(crate) async fn verification_view(
State(state): State<SharedState>,
Query(params): Query<ViewParams>,
) -> Response {
let state = state.read().await;
let ctx = state.as_render_context();
Html(crate::render::results::render_verification_view(&ctx))
let scope = match try_build_scope(&state, &params.variant) {
Ok(s) => s,
Err(resp) => return resp,
};
let html = match scope.as_ref() {
Some(s) => crate::render::results::render_verification_view(&s.render_context(&state)),
None => crate::render::results::render_verification_view(&state.as_render_context()),
};
Html(html).into_response()
}

// ── STPA ─────────────────────────────────────────────────────────────────
Expand All @@ -371,18 +427,38 @@ pub(crate) async fn stpa_view(
// ── EU AI Act ────────────────────────────────────────────────────────────

/// GET /eu-ai-act — EU AI Act Annex IV compliance dashboard.
pub(crate) async fn eu_ai_act_view(State(state): State<SharedState>) -> Html<String> {
pub(crate) async fn eu_ai_act_view(
State(state): State<SharedState>,
Query(params): Query<ViewParams>,
) -> Response {
let state = state.read().await;
let ctx = state.as_render_context();
Html(crate::render::eu_ai_act::render_eu_ai_act(&ctx))
let scope = match try_build_scope(&state, &params.variant) {
Ok(s) => s,
Err(resp) => return resp,
};
let html = match scope.as_ref() {
Some(s) => crate::render::eu_ai_act::render_eu_ai_act(&s.render_context(&state)),
None => crate::render::eu_ai_act::render_eu_ai_act(&state.as_render_context()),
};
Html(html).into_response()
}

// ── Results ──────────────────────────────────────────────────────────────

pub(crate) async fn results_view(State(state): State<SharedState>) -> Html<String> {
pub(crate) async fn results_view(
State(state): State<SharedState>,
Query(params): Query<ViewParams>,
) -> Response {
let state = state.read().await;
let ctx = state.as_render_context();
Html(crate::render::results::render_results_view(&ctx))
let scope = match try_build_scope(&state, &params.variant) {
Ok(s) => s,
Err(resp) => return resp,
};
let html = match scope.as_ref() {
Some(s) => crate::render::results::render_results_view(&s.render_context(&state)),
None => crate::render::results::render_results_view(&state.as_render_context()),
};
Html(html).into_response()
}

pub(crate) async fn result_detail(
Expand Down Expand Up @@ -436,10 +512,20 @@ pub(crate) async fn diff_view(

// ── Document linkage view ────────────────────────────────────────────────

pub(crate) async fn doc_linkage_view(State(state): State<SharedState>) -> Html<String> {
pub(crate) async fn doc_linkage_view(
State(state): State<SharedState>,
Query(params): Query<ViewParams>,
) -> Response {
let state = state.read().await;
let ctx = state.as_render_context();
Html(crate::render::doc_linkage::render_doc_linkage_view(&ctx))
let scope = match try_build_scope(&state, &params.variant) {
Ok(s) => s,
Err(resp) => return resp,
};
let html = match scope.as_ref() {
Some(s) => crate::render::doc_linkage::render_doc_linkage_view(&s.render_context(&state)),
None => crate::render::doc_linkage::render_doc_linkage_view(&state.as_render_context()),
};
Html(html).into_response()
}

// ── Traceability explorer ────────────────────────────────────────────────
Expand All @@ -449,6 +535,10 @@ pub(crate) struct TraceParams {
root_type: Option<String>,
status: Option<String>,
search: Option<String>,
/// Active variant scope (by name). When set, the traceability tree
/// is built against artifacts bound to the variant's effective
/// features.
variant: Option<String>,
}

#[derive(Debug, serde::Deserialize)]
Expand All @@ -459,17 +549,28 @@ pub(crate) struct TraceHistoryParams {
pub(crate) async fn traceability_view(
State(state): State<SharedState>,
Query(params): Query<TraceParams>,
) -> Html<String> {
) -> Response {
let state = state.read().await;
let ctx = state.as_render_context();
let scope = match try_build_scope(&state, &params.variant) {
Ok(s) => s,
Err(resp) => return resp,
};
let rparams = crate::render::traceability::TraceParams {
root_type: params.root_type,
status: params.status,
search: params.search,
};
Html(crate::render::traceability::render_traceability_view(
&ctx, &rparams,
))
let html = match scope.as_ref() {
Some(s) => crate::render::traceability::render_traceability_view(
&s.render_context(&state),
&rparams,
),
None => crate::render::traceability::render_traceability_view(
&state.as_render_context(),
&rparams,
),
};
Html(html).into_response()
}

pub(crate) async fn traceability_history(
Expand Down
130 changes: 130 additions & 0 deletions rivet-cli/tests/serve_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,136 @@ fn embed_artifact_returns_200_with_embed_layout() {
child.wait().ok();
}

// ── Variant scoping: handlers closed in feat/variant-scoping-coherence ──
//
// Before the fix, eight dashboard handlers silently dropped `?variant=`
// while the layout banner showed scoped — a coherence bug pinned by the
// Playwright "is silently UNSCOPED" test in rendering-invariants.spec.ts.
//
// These integration tests verify that each handler now (a) still returns
// 200 when given `?variant=minimal-ci`, (b) renders the variant banner
// (proving the same param the layout reads is the param the handler
// honors), and (c) returns 400 with the standard error fragment for an
// unknown variant — the same contract artifacts_list / coverage_view
// already implement.

fn assert_scoped_ok(port: u16, route: &str) {
let (status, body, _) = fetch(port, &format!("{route}?variant=minimal-ci"), false);
assert_eq!(status, 200, "{route} should return 200 when scoped");
assert!(
body.contains("Filtered to variant"),
"{route} should render the variant banner when ?variant is set"
);
assert!(
body.contains("minimal-ci"),
"{route} banner should name the active variant"
);
}

fn assert_scoped_400_on_unknown(port: u16, route: &str) {
let (status, body, _) = fetch(port, &format!("{route}?variant=does-not-exist"), false);
assert_eq!(
status, 400,
"{route} should return 400 for an unknown variant name"
);
assert!(
body.contains("Invalid variant scope"),
"{route} 400 body should be the standard variant_error_response"
);
}

#[test]
fn newly_scoped_handlers_render_variant_banner() {
// Pin the per-handler scoping contract for the eight handlers closed
// by feat/variant-scoping-coherence: each route must (a) accept
// `?variant=minimal-ci`, (b) render the layout banner, (c) reject
// unknown variant names with 400.
//
// /search is excluded here because the wrap_full_page middleware
// intentionally does NOT wrap /search responses (it is a fragment-only
// endpoint consumed by the Cmd+K JS via fetch). It still has the same
// variant-scoping contract, just no banner — see the dedicated test
// `search_handler_honors_variant_scope` below.
//
// /externals is intentionally excluded from the strict-scoping list
// (Choice C: explicit ignore + comment in views.rs) — externals are
// loaded from sibling repos and don't participate in this project's
// feature-model bindings, so a variant filter has no semantic meaning
// there. The asymmetry is documented inline in views::externals_list.
let (mut child, port) = start_server();

for route in [
"/graph",
"/verification",
"/eu-ai-act",
"/traceability",
"/doc-linkage",
"/documents",
"/results",
] {
assert_scoped_ok(port, route);
assert_scoped_400_on_unknown(port, route);
}

child.kill().ok();
child.wait().ok();
}

#[test]
fn search_handler_honors_variant_scope() {
// /search is a fragment endpoint excluded from the layout middleware,
// so it does NOT carry the variant banner. But the scoping contract
// is otherwise the same as the seven banner-routes:
// * unknown variant → 400 with the "Invalid variant scope" body
// * valid variant → 200 and the search results are filtered
// against the scoped store.
let (mut child, port) = start_server();

let (status_ok, body_ok, _) = fetch(port, "/search?q=REQ&variant=minimal-ci", false);
assert_eq!(status_ok, 200, "/search should return 200 when scoped");
// Non-empty body — the fragment shouldn't be a server error.
assert!(
!body_ok.contains("thread 'main' panicked"),
"/search should not panic when scoped"
);

let (status_bad, body_bad, _) = fetch(port, "/search?q=REQ&variant=does-not-exist", false);
assert_eq!(
status_bad, 400,
"/search should reject unknown variant names"
);
assert!(
body_bad.contains("Invalid variant scope"),
"/search 400 body should be the standard variant_error_response"
);

child.kill().ok();
child.wait().ok();
}

#[test]
fn externals_view_accepts_variant_param_without_400() {
// /externals deliberately ignores `variant` (per the Choice C comment
// in views::externals_list). The handler must still accept the param
// gracefully so bookmarked URLs like /externals?variant=minimal-ci
// don't error — the layout banner picks up the param via middleware.
let (mut child, port) = start_server();
let (status, _body, _) = fetch(port, "/externals?variant=minimal-ci", false);
assert_eq!(
status, 200,
"/externals must accept ?variant gracefully even when it has no semantic effect"
);
let (status_unknown, _, _) = fetch(port, "/externals?variant=does-not-exist", false);
// Choice C means the handler doesn't validate the variant — the layout
// still renders the page and the banner shows the error inline.
assert_eq!(
status_unknown, 200,
"/externals must not reject unknown variants — Choice C (explicit ignore)"
);
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
Expand Down
Loading
Loading