diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 169442a..0934b4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,6 +270,7 @@ jobs: name: Mutation Testing needs: [test] runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable diff --git a/.gitignore b/.gitignore index e8fb3de..3c46b74 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.swo .DS_Store .claude/worktrees/ +.worktrees/ # WASM binary assets (built or downloaded, not committed) rivet-cli/assets/wasm/*.wasm diff --git a/artifacts/features.yaml b/artifacts/features.yaml index 9a9f96b..527b8ad 100644 --- a/artifacts/features.yaml +++ b/artifacts/features.yaml @@ -1248,6 +1248,23 @@ artifacts: phase: future baseline: v0.4.0 + - id: FEAT-073 + type: feature + title: JSON API with oEmbed and CORS + status: draft + description: > + REST JSON API under /api/v1/ with permissive CORS for Grafana + dashboard embedding. Includes health, stats, artifacts, diagnostics, + coverage endpoints plus an oEmbed provider at /oembed for rich + embeds of artifact detail pages. + tags: [api, serve, phase-3] + links: + - type: satisfies + target: REQ-007 + fields: + phase: phase-3 + baseline: v0.3.1 + - id: FEAT-070 type: feature title: Draft-aware validation severity diff --git a/etch/src/layout.rs b/etch/src/layout.rs index cb0d5c8..08a472b 100644 --- a/etch/src/layout.rs +++ b/etch/src/layout.rs @@ -777,7 +777,17 @@ fn route_edges( let node_pos: HashMap<&str, &LayoutNode> = layout_nodes.iter().map(|n| (n.id.as_str(), n)).collect(); - let mut edges: Vec = Vec::new(); + // Collect edge metadata and endpoints. + struct EdgeData { + src_id: String, + tgt_id: String, + info: EdgeInfo, + start: (f64, f64), + end: (f64, f64), + is_ortho: bool, + } + + let mut edge_data: Vec = Vec::new(); for edge_ref in graph.edge_references() { let src_idx = edge_ref.source(); @@ -824,30 +834,63 @@ fn route_edges( }); let end = tgt_point.unwrap_or_else(|| (tgt_node.x + tgt_node.width / 2.0, tgt_node.y)); - let points = match options.edge_routing { - EdgeRouting::Orthogonal => crate::ortho::route_orthogonal( - layout_nodes, - start, - end, - options.bend_penalty, - options.port_stub_length, - ), - EdgeRouting::CubicBezier => { - if src_point.is_some() || tgt_point.is_some() { - vec![start, end] - } else { - compute_waypoints(src_node, tgt_node, options) - } + let is_ortho = matches!(options.edge_routing, EdgeRouting::Orthogonal); + + edge_data.push(EdgeData { + src_id: src_id.clone(), + tgt_id: tgt_id.clone(), + info, + start, + end, + is_ortho, + }); + } + + // Batch-route orthogonal edges for nudging support. + let ortho_endpoints: Vec<((f64, f64), (f64, f64))> = edge_data + .iter() + .filter(|e| e.is_ortho) + .map(|e| (e.start, e.end)) + .collect(); + + let ortho_paths = if !ortho_endpoints.is_empty() { + crate::ortho::route_orthogonal_batch( + layout_nodes, + &ortho_endpoints, + options.bend_penalty, + options.port_stub_length, + options.edge_separation, + ) + } else { + Vec::new() + }; + + // Assign routed paths back to edges. + let mut ortho_idx = 0; + let mut edges: Vec = Vec::new(); + + for ed in &edge_data { + let points = if ed.is_ortho { + let p = ortho_paths[ortho_idx].clone(); + ortho_idx += 1; + p + } else { + let src_node = node_pos[ed.src_id.as_str()]; + let tgt_node = node_pos[ed.tgt_id.as_str()]; + if ed.info.source_port.is_some() || ed.info.target_port.is_some() { + vec![ed.start, ed.end] + } else { + compute_waypoints(src_node, tgt_node, options) } }; edges.push(LayoutEdge { - source_id: src_id.clone(), - target_id: tgt_id.clone(), - label: info.label, + source_id: ed.src_id.clone(), + target_id: ed.tgt_id.clone(), + label: ed.info.label.clone(), points, - source_port: info.source_port, - target_port: info.target_port, + source_port: ed.info.source_port.clone(), + target_port: ed.info.target_port.clone(), }); } @@ -1724,8 +1767,15 @@ mod tests { .find(|e| e.source_id == "A" && e.target_id == "C") .expect("should find A->C edge"); - // A->C spans ranks 0..2, so should have 3 waypoints (start, mid, end). - assert_eq!(long_edge.points.len(), 3); + // A->C spans ranks 0..2, so should have intermediate waypoints. + // The orthogonal router may add port stubs and extra bends, so we + // check for at least 3 points (start, intermediate(s), end) rather + // than an exact count. + assert!( + long_edge.points.len() >= 3, + "A->C should have at least 3 waypoints, got {}", + long_edge.points.len() + ); } // ----------------------------------------------------------------------- diff --git a/etch/src/ortho.rs b/etch/src/ortho.rs index 3bb5c7b..1b175c7 100644 --- a/etch/src/ortho.rs +++ b/etch/src/ortho.rs @@ -1,11 +1,14 @@ //! Orthogonal edge routing with obstacle avoidance. //! //! Routes edges as sequences of horizontal and vertical line segments, -//! avoiding node rectangles. Uses a simplified visibility-graph approach: +//! avoiding node rectangles. Uses a visibility-graph approach: //! //! 1. Build padded obstacle rectangles from all nodes. -//! 2. Generate candidate waypoints at obstacle corners. -//! 3. Find shortest orthogonal path using A* with bend penalty. +//! 2. Generate candidate waypoints at obstacle corners and routing channels. +//! 3. Find shortest orthogonal path using A* with Manhattan heuristic and +//! bend penalty. +//! 4. Simplify collinear waypoints. +//! 5. Nudge overlapping parallel segments apart (batch mode). use std::cmp::Ordering; use std::collections::{BinaryHeap, HashMap}; @@ -13,7 +16,11 @@ use std::collections::{BinaryHeap, HashMap}; use crate::layout::LayoutNode; /// Padding around obstacle rectangles (px). -const OBSTACLE_PADDING: f64 = 6.0; +const OBSTACLE_PADDING: f64 = 8.0; + +/// Extra clearance beyond obstacle padding for waypoints, so edges +/// don't hug node boundaries (px). +const WAYPOINT_MARGIN: f64 = 4.0; /// An axis-aligned rectangle used as an obstacle. #[derive(Debug, Clone, Copy)] @@ -29,12 +36,19 @@ impl Rect { x >= self.x1 && x <= self.x2 && y >= self.y1 && y <= self.y2 } + /// Check strictly inside (not on boundary). + fn contains_strict(&self, x: f64, y: f64) -> bool { + x > self.x1 + 0.01 && x < self.x2 - 0.01 && y > self.y1 + 0.01 && y < self.y2 - 0.01 + } + fn intersects_segment(&self, ax: f64, ay: f64, bx: f64, by: f64) -> bool { - // Check if horizontal or vertical segment intersects this rectangle + // Check if horizontal or vertical segment intersects this rectangle. + // Uses strict interior check so edges along obstacle boundaries are + // not falsely blocked. if (ay - by).abs() < 0.001 { // Horizontal segment let y = ay; - if y < self.y1 || y > self.y2 { + if y <= self.y1 || y >= self.y2 { return false; } let min_x = ax.min(bx); @@ -43,7 +57,7 @@ impl Rect { } else if (ax - bx).abs() < 0.001 { // Vertical segment let x = ax; - if x < self.x1 || x > self.x2 { + if x <= self.x1 || x >= self.x2 { return false; } let min_y = ay.min(by); @@ -60,7 +74,10 @@ impl Rect { struct PathNode { x: f64, y: f64, - cost: f64, + /// g-cost: actual distance from start. + g: f64, + /// f-cost: g + heuristic. + f: f64, /// Direction of the segment leading to this node (for bend penalty). /// 0 = start, 1 = horizontal, 2 = vertical dir: u8, @@ -68,7 +85,7 @@ struct PathNode { impl PartialEq for PathNode { fn eq(&self, other: &Self) -> bool { - self.cost == other.cost + self.f == other.f } } @@ -82,17 +99,25 @@ impl PartialOrd for PathNode { impl Ord for PathNode { fn cmp(&self, other: &Self) -> Ordering { - // Reverse ordering for min-heap + // Reverse ordering for min-heap (lower f-cost = higher priority). + // Break ties by preferring lower g-cost (closer to start). other - .cost - .partial_cmp(&self.cost) + .f + .partial_cmp(&self.f) .unwrap_or(Ordering::Equal) + .then_with(|| self.g.partial_cmp(&other.g).unwrap_or(Ordering::Equal)) } } /// Discretize a coordinate for use as HashMap key. +/// Uses rounding instead of truncation for better precision. fn grid_key(x: f64, y: f64) -> (i64, i64) { - ((x * 100.0) as i64, (y * 100.0) as i64) + ((x * 100.0).round() as i64, (y * 100.0).round() as i64) +} + +/// Manhattan distance heuristic for A*. +fn manhattan(ax: f64, ay: f64, bx: f64, by: f64) -> f64 { + (ax - bx).abs() + (ay - by).abs() } /// Route an edge orthogonally from `src` to `tgt`, avoiding obstacles. @@ -104,40 +129,173 @@ pub fn route_orthogonal( src: (f64, f64), tgt: (f64, f64), bend_penalty: f64, - _port_stub_length: f64, + port_stub_length: f64, ) -> Vec<(f64, f64)> { // Trivial case: same point if (src.0 - tgt.0).abs() < 0.001 && (src.1 - tgt.1).abs() < 0.001 { return vec![src]; } - // If source and target share an axis, try direct line let obstacles = build_obstacles(nodes); - if can_route_direct(&obstacles, src, tgt) { - return if (src.0 - tgt.0).abs() < 0.001 || (src.1 - tgt.1).abs() < 0.001 { - vec![src, tgt] + // Add port stubs: extend straight out from src/tgt before routing. + // This ensures edges leave ports cleanly rather than bending immediately. + let (effective_src, src_stub) = compute_port_stub(src, tgt, port_stub_length, &obstacles); + let (effective_tgt, tgt_stub) = compute_port_stub(tgt, src, port_stub_length, &obstacles); + + // If source and target share an axis after stubs, try direct line + if can_route_direct(&obstacles, effective_src, effective_tgt) { + let mut path = Vec::new(); + if let Some(s) = src_stub { + path.push(src); + path.push(s); + } + if (effective_src.0 - effective_tgt.0).abs() < 0.001 + || (effective_src.1 - effective_tgt.1).abs() < 0.001 + { + if path.is_empty() { + path.push(effective_src); + } + path.push(effective_tgt); } else { - // One bend: go horizontal then vertical - let mid = (tgt.0, src.1); - if !segment_blocked(&obstacles, src.0, src.1, mid.0, mid.1) - && !segment_blocked(&obstacles, mid.0, mid.1, tgt.0, tgt.1) + // One bend: try horizontal-then-vertical, then vertical-then-horizontal + if path.is_empty() { + path.push(effective_src); + } + let mid = (effective_tgt.0, effective_src.1); + if !segment_blocked(&obstacles, effective_src.0, effective_src.1, mid.0, mid.1) + && !segment_blocked(&obstacles, mid.0, mid.1, effective_tgt.0, effective_tgt.1) { - vec![src, mid, tgt] + path.push(mid); + path.push(effective_tgt); } else { - let mid2 = (src.0, tgt.1); - if !segment_blocked(&obstacles, src.0, src.1, mid2.0, mid2.1) - && !segment_blocked(&obstacles, mid2.0, mid2.1, tgt.0, tgt.1) + let mid2 = (effective_src.0, effective_tgt.1); + if !segment_blocked(&obstacles, effective_src.0, effective_src.1, mid2.0, mid2.1) + && !segment_blocked( + &obstacles, + mid2.0, + mid2.1, + effective_tgt.0, + effective_tgt.1, + ) { - vec![src, mid2, tgt] + path.push(mid2); + path.push(effective_tgt); } else { - route_with_astar(&obstacles, src, tgt, bend_penalty) + // Fall through to A* + let mut astar_path = + route_with_astar(&obstacles, effective_src, effective_tgt, bend_penalty); + let mut result = Vec::new(); + if let Some(s) = src_stub { + result.push(src); + result.push(s); + } + if !result.is_empty() + && !astar_path.is_empty() + && (astar_path[0].0 - effective_src.0).abs() < 0.1 + && (astar_path[0].1 - effective_src.1).abs() < 0.1 + { + astar_path.remove(0); + } + result.extend(astar_path); + if let Some(s) = tgt_stub { + result.push(s); + result.push(tgt); + } + return simplify_path(result); } } - }; + } + if let Some(s) = tgt_stub { + path.push(s); + path.push(tgt); + } + return simplify_path(path); + } + + // Full A* routing + let mut astar_path = route_with_astar(&obstacles, effective_src, effective_tgt, bend_penalty); + + let mut result = Vec::new(); + if let Some(s) = src_stub { + result.push(src); + result.push(s); + } + if !result.is_empty() + && !astar_path.is_empty() + && (astar_path[0].0 - effective_src.0).abs() < 0.1 + && (astar_path[0].1 - effective_src.1).abs() < 0.1 + { + astar_path.remove(0); + } + result.extend(astar_path); + if let Some(s) = tgt_stub { + result.push(s); + result.push(tgt); } - route_with_astar(&obstacles, src, tgt, bend_penalty) + simplify_path(result) +} + +/// A source-target point pair for batch routing. +pub type EdgeEndpoints = ((f64, f64), (f64, f64)); + +/// Route multiple edges as a batch, then nudge overlapping parallel segments +/// apart so they don't visually overlap. +/// +/// `edge_separation` controls the gap between adjacent parallel segments. +pub fn route_orthogonal_batch( + nodes: &[LayoutNode], + edges: &[EdgeEndpoints], + bend_penalty: f64, + port_stub_length: f64, + edge_separation: f64, +) -> Vec> { + // Route each edge individually first. + let mut paths: Vec> = edges + .iter() + .map(|&(src, tgt)| route_orthogonal(nodes, src, tgt, bend_penalty, port_stub_length)) + .collect(); + + // Nudge overlapping parallel segments apart. + nudge_parallel_segments(&mut paths, edge_separation); + + paths +} + +/// Compute a port stub point: extends straight from `from` away from `toward`. +/// Returns (effective routing point, optional stub waypoint). +fn compute_port_stub( + from: (f64, f64), + toward: (f64, f64), + stub_length: f64, + obstacles: &[Rect], +) -> ((f64, f64), Option<(f64, f64)>) { + if stub_length <= 0.0 { + return (from, None); + } + + // Determine primary direction: if the target is mostly below, extend down; + // if mostly above, extend up; if mostly right, extend right; etc. + let dx = toward.0 - from.0; + let dy = toward.1 - from.1; + + let stub = if dy.abs() >= dx.abs() { + // Vertical stub + let dir = if dy >= 0.0 { 1.0 } else { -1.0 }; + (from.0, from.1 + dir * stub_length) + } else { + // Horizontal stub + let dir = if dx >= 0.0 { 1.0 } else { -1.0 }; + (from.0 + dir * stub_length, from.1) + }; + + // Only use stub if it doesn't go inside an obstacle + if obstacles.iter().any(|r| r.contains_strict(stub.0, stub.1)) { + return (from, None); + } + + (stub, Some(stub)) } fn build_obstacles(nodes: &[LayoutNode]) -> Vec { @@ -172,29 +330,36 @@ fn route_with_astar( tgt: (f64, f64), bend_penalty: f64, ) -> Vec<(f64, f64)> { - // Generate candidate waypoints from obstacle corners + src/tgt + let margin = WAYPOINT_MARGIN; + + // Generate candidate waypoints from obstacle corners + src/tgt. + // Place waypoints with extra margin so edges don't hug node boundaries. let mut candidates: Vec<(f64, f64)> = vec![src, tgt]; for r in obstacles { - // Add corner points (slightly outside the obstacle) - candidates.push((r.x1, r.y1)); - candidates.push((r.x2, r.y1)); - candidates.push((r.x1, r.y2)); - candidates.push((r.x2, r.y2)); + // Corner points with additional clearance margin + candidates.push((r.x1 - margin, r.y1 - margin)); + candidates.push((r.x2 + margin, r.y1 - margin)); + candidates.push((r.x1 - margin, r.y2 + margin)); + candidates.push((r.x2 + margin, r.y2 + margin)); } - // Also add axis-aligned projections of src/tgt through obstacle corners + // Add axis-aligned projections of src/tgt through obstacle edges (with margin) for r in obstacles { - candidates.push((src.0, r.y1)); - candidates.push((src.0, r.y2)); - candidates.push((r.x1, src.1)); - candidates.push((r.x2, src.1)); - candidates.push((tgt.0, r.y1)); - candidates.push((tgt.0, r.y2)); - candidates.push((r.x1, tgt.1)); - candidates.push((r.x2, tgt.1)); + candidates.push((src.0, r.y1 - margin)); + candidates.push((src.0, r.y2 + margin)); + candidates.push((r.x1 - margin, src.1)); + candidates.push((r.x2 + margin, src.1)); + candidates.push((tgt.0, r.y1 - margin)); + candidates.push((tgt.0, r.y2 + margin)); + candidates.push((r.x1 - margin, tgt.1)); + candidates.push((r.x2 + margin, tgt.1)); } + // Add mid-channel waypoints between adjacent obstacles (horizontal and vertical). + // These provide natural routing channels through dense layouts. + add_channel_waypoints(&mut candidates, obstacles, src, tgt); + // Filter out candidates inside obstacles candidates.retain(|&(x, y)| !obstacles.iter().any(|r| r.contains(x, y))); @@ -206,22 +371,25 @@ fn route_with_astar( }); candidates.dedup_by(|a, b| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01); - // A* search + // A* search with Manhattan heuristic let src_key = grid_key(src.0, src.1); let tgt_key = grid_key(tgt.0, tgt.1); let mut heap = BinaryHeap::new(); type GridKey = (i64, i64); - // (cost, direction, predecessor) - let mut best: HashMap)> = HashMap::new(); + // Map from grid key to (g-cost, direction, predecessor key, exact x, exact y) + let mut best: HashMap, f64, f64)> = HashMap::new(); + + let h0 = manhattan(src.0, src.1, tgt.0, tgt.1); heap.push(PathNode { x: src.0, y: src.1, - cost: 0.0, + g: 0.0, + f: h0, dir: 0, }); - best.insert(src_key, (0.0, 0, None)); + best.insert(src_key, (0.0, 0, None, src.0, src.1)); while let Some(current) = heap.pop() { let cur_key = grid_key(current.x, current.y); @@ -230,10 +398,10 @@ fn route_with_astar( break; } - if let Some(&(best_cost, _, _)) = best.get(&cur_key) - && current.cost > best_cost + 0.001 - { - continue; + if let Some(&(best_g, _, _, _, _)) = best.get(&cur_key) { + if current.g > best_g + 0.001 { + continue; + } } // Try reaching each candidate via orthogonal segment @@ -269,38 +437,44 @@ fn route_with_astar( 0.0 }; - let new_cost = current.cost + dist + bend_cost; + let new_g = current.g + dist + bend_cost; let is_better = match best.get(&c_key) { - Some(&(prev_cost, _, _)) => new_cost < prev_cost - 0.001, + Some(&(prev_g, _, _, _, _)) => new_g < prev_g - 0.001, None => true, }; if is_better { - best.insert(c_key, (new_cost, dir, Some(cur_key))); + let h = manhattan(cx, cy, tgt.0, tgt.1); + best.insert(c_key, (new_g, dir, Some(cur_key), cx, cy)); heap.push(PathNode { x: cx, y: cy, - cost: new_cost, + g: new_g, + f: new_g + h, dir, }); } } } - // Reconstruct path + // Reconstruct path using exact coordinates stored in `best` let mut path = Vec::new(); let mut key = tgt_key; loop { match best.get(&key) { - Some(&(_, _, Some(prev))) => { - // Find the point for this key - let (x, y) = (key.0 as f64 / 100.0, key.1 as f64 / 100.0); + Some(&(_, _, Some(prev), x, y)) => { path.push((x, y)); key = prev; } - _ => { + Some(&(_, _, None, x, y)) => { + // This is the source node + path.push((x, y)); + break; + } + None => { + // Target was never reached — fallback path.push(src); break; } @@ -315,9 +489,212 @@ fn route_with_astar( return vec![src, mid, tgt]; } + simplify_path(path) +} + +/// Add mid-channel waypoints between adjacent obstacles. +/// +/// When two obstacles are separated by a gap, add waypoints at the midpoint +/// of the gap on the relevant axes. This gives the router natural channels +/// to route through instead of hugging obstacle corners. +fn add_channel_waypoints( + candidates: &mut Vec<(f64, f64)>, + obstacles: &[Rect], + src: (f64, f64), + tgt: (f64, f64), +) { + let margin = WAYPOINT_MARGIN; + + // Collect all unique x and y coordinates from obstacles + let mut xs: Vec = Vec::new(); + let mut ys: Vec = Vec::new(); + for r in obstacles { + xs.push(r.x1 - margin); + xs.push(r.x2 + margin); + ys.push(r.y1 - margin); + ys.push(r.y2 + margin); + } + xs.push(src.0); + xs.push(tgt.0); + ys.push(src.1); + ys.push(tgt.1); + + xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + xs.dedup_by(|a, b| (*a - *b).abs() < 1.0); + ys.dedup_by(|a, b| (*a - *b).abs() < 1.0); + + // For each pair of adjacent x-coordinates, add midpoint channel waypoints + for pair in xs.windows(2) { + let mid_x = (pair[0] + pair[1]) / 2.0; + for &y in &ys { + candidates.push((mid_x, y)); + } + } + + // For each pair of adjacent y-coordinates, add midpoint channel waypoints + for pair in ys.windows(2) { + let mid_y = (pair[0] + pair[1]) / 2.0; + for &x in &xs { + candidates.push((x, mid_y)); + } + } +} + +/// Remove redundant collinear waypoints from a path. +/// +/// If three consecutive points lie on the same horizontal or vertical line, +/// the middle point is redundant and can be removed. +fn simplify_path(mut path: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + if path.len() <= 2 { + return path; + } + + let mut i = 1; + while i < path.len().saturating_sub(1) { + let prev = path[i - 1]; + let curr = path[i]; + let next = path[i + 1]; + + let collinear_h = (prev.1 - curr.1).abs() < 0.01 && (curr.1 - next.1).abs() < 0.01; + let collinear_v = (prev.0 - curr.0).abs() < 0.01 && (curr.0 - next.0).abs() < 0.01; + + if collinear_h || collinear_v { + path.remove(i); + } else { + i += 1; + } + } + path } +/// Nudge overlapping parallel segments apart. +/// +/// Collects all horizontal and vertical segments from all paths, groups +/// overlapping parallel segments, and offsets them symmetrically so they +/// don't visually overlap. +fn nudge_parallel_segments(paths: &mut [Vec<(f64, f64)>], separation: f64) { + if separation <= 0.0 || paths.len() < 2 { + return; + } + + // Collect all segments with their path/segment indices. + // Each segment is (path_idx, seg_idx, is_horizontal, fixed_coord, min_var, max_var). + let mut h_segments: Vec<(usize, usize, f64, f64, f64)> = Vec::new(); // (path, seg, y, min_x, max_x) + let mut v_segments: Vec<(usize, usize, f64, f64, f64)> = Vec::new(); // (path, seg, x, min_y, max_y) + + for (pi, path) in paths.iter().enumerate() { + for si in 0..path.len().saturating_sub(1) { + let (x1, y1) = path[si]; + let (x2, y2) = path[si + 1]; + + if (y1 - y2).abs() < 0.01 { + // Horizontal segment + h_segments.push((pi, si, y1, x1.min(x2), x1.max(x2))); + } else if (x1 - x2).abs() < 0.01 { + // Vertical segment + v_segments.push((pi, si, x1, y1.min(y2), y1.max(y2))); + } + } + } + + // Nudge overlapping horizontal segments (same y, overlapping x-range) + nudge_group(&h_segments, paths, separation, true); + + // Nudge overlapping vertical segments (same x, overlapping y-range) + nudge_group(&v_segments, paths, separation, false); +} + +/// Find groups of overlapping segments on the same line and nudge them apart. +fn nudge_group( + segments: &[(usize, usize, f64, f64, f64)], + paths: &mut [Vec<(f64, f64)>], + separation: f64, + is_horizontal: bool, +) { + // Group segments by their fixed coordinate (within tolerance) + let tolerance = 0.5; + + // Sort by fixed coordinate + let mut sorted: Vec = (0..segments.len()).collect(); + sorted.sort_by(|&a, &b| { + segments[a] + .2 + .partial_cmp(&segments[b].2) + .unwrap_or(Ordering::Equal) + }); + + let mut i = 0; + while i < sorted.len() { + let fixed = segments[sorted[i]].2; + + // Collect all segments with same fixed coordinate + let mut group: Vec = vec![sorted[i]]; + let mut j = i + 1; + while j < sorted.len() && (segments[sorted[j]].2 - fixed).abs() < tolerance { + group.push(sorted[j]); + j += 1; + } + + if group.len() > 1 { + // Find overlapping subsets within this group + // For each pair, check if their variable ranges overlap + let mut overlapping: Vec> = Vec::new(); + let mut used = vec![false; group.len()]; + + for gi in 0..group.len() { + if used[gi] { + continue; + } + let mut cluster = vec![gi]; + used[gi] = true; + + for gj in (gi + 1)..group.len() { + if used[gj] { + continue; + } + // Check if any segment in cluster overlaps with gj + let seg_j = &segments[group[gj]]; + let overlaps = cluster.iter().any(|&ck| { + let seg_k = &segments[group[ck]]; + seg_k.3 < seg_j.4 && seg_j.3 < seg_k.4 + }); + if overlaps { + cluster.push(gj); + used[gj] = true; + } + } + + if cluster.len() > 1 { + overlapping.push(cluster.iter().map(|&ci| group[ci]).collect()); + } + } + + // Nudge each overlapping cluster + for cluster in &overlapping { + let n = cluster.len(); + for (rank, &seg_idx) in cluster.iter().enumerate() { + let offset = (rank as f64 - (n - 1) as f64 / 2.0) * separation; + let (pi, si, _, _, _) = segments[seg_idx]; + + if is_horizontal { + // Nudge y coordinate of both endpoints of this segment + paths[pi][si].1 += offset; + paths[pi][si + 1].1 += offset; + } else { + // Nudge x coordinate of both endpoints of this segment + paths[pi][si].0 += offset; + paths[pi][si + 1].0 += offset; + } + } + } + } + + i = j; + } +} + #[cfg(test)] mod tests { use super::*; @@ -339,6 +716,48 @@ mod tests { } } + /// Assert all consecutive segments are orthogonal (horizontal or vertical). + fn assert_orthogonal(path: &[(f64, f64)]) { + for w in path.windows(2) { + let dx = (w[0].0 - w[1].0).abs(); + let dy = (w[0].1 - w[1].1).abs(); + assert!( + dx < 0.1 || dy < 0.1, + "non-orthogonal: ({:.1},{:.1})->({:.1},{:.1})", + w[0].0, + w[0].1, + w[1].0, + w[1].1 + ); + } + } + + /// Assert that no path segment passes through any obstacle interior. + fn assert_no_obstacle_penetration(path: &[(f64, f64)], nodes: &[LayoutNode]) { + let obstacles = build_obstacles(nodes); + for w in path.windows(2) { + for (oi, obs) in obstacles.iter().enumerate() { + // Allow segments along obstacle boundaries but not through interiors. + // Use a slightly shrunk rect for the "strictly interior" check. + let shrunk = Rect { + x1: obs.x1 + 0.1, + y1: obs.y1 + 0.1, + x2: obs.x2 - 0.1, + y2: obs.y2 - 0.1, + }; + assert!( + !shrunk.intersects_segment(w[0].0, w[0].1, w[1].0, w[1].1), + "segment ({:.1},{:.1})->({:.1},{:.1}) penetrates obstacle {}", + w[0].0, + w[0].1, + w[1].0, + w[1].1, + oi + ); + } + } + } + #[test] fn direct_vertical_no_obstacles() { let nodes = vec![]; @@ -359,21 +778,9 @@ mod tests { fn l_shaped_no_obstacles() { let nodes = vec![]; let path = route_orthogonal(&nodes, (0.0, 0.0), (200.0, 200.0), 20.0, 10.0); - // Should have one bend (3 points) + // Should have at least one bend assert!(path.len() >= 2); - // All segments orthogonal - for w in path.windows(2) { - let dx = (w[0].0 - w[1].0).abs(); - let dy = (w[0].1 - w[1].1).abs(); - assert!( - dx < 0.1 || dy < 0.1, - "non-orthogonal: ({},{})->({},{})", - w[0].0, - w[0].1, - w[1].0, - w[1].1 - ); - } + assert_orthogonal(&path); } #[test] @@ -389,12 +796,7 @@ mod tests { path.len() ); - // All segments orthogonal - for w in path.windows(2) { - let dx = (w[0].0 - w[1].0).abs(); - let dy = (w[0].1 - w[1].1).abs(); - assert!(dx < 0.1 || dy < 0.1, "non-orthogonal segment"); - } + assert_orthogonal(&path); } #[test] @@ -405,18 +807,235 @@ mod tests { make_node("C", 100.0, 100.0, 80.0, 40.0), ]; let path = route_orthogonal(&nodes, (80.0, 20.0), (200.0, 120.0), 20.0, 10.0); + assert_orthogonal(&path); + } - for w in path.windows(2) { - let dx = (w[0].0 - w[1].0).abs(); - let dy = (w[0].1 - w[1].1).abs(); - assert!( - dx < 0.1 || dy < 0.1, - "non-orthogonal: ({:.1},{:.1})->({:.1},{:.1})", - w[0].0, - w[0].1, - w[1].0, - w[1].1 - ); + // --- New tests for improvements --- + + #[test] + fn port_stub_creates_initial_straight_segment() { + let nodes = vec![]; + let path = route_orthogonal(&nodes, (100.0, 0.0), (200.0, 200.0), 20.0, 15.0); + + // With a 15px stub, the first segment should be vertical from (100,0) + assert!( + path.len() >= 3, + "should have stub + bend, got {} points", + path.len() + ); + // First segment should be along the dominant axis (vertical, toward target) + assert!( + (path[0].0 - path[1].0).abs() < 0.1 || (path[0].1 - path[1].1).abs() < 0.1, + "first segment should be axis-aligned" + ); + assert_orthogonal(&path); + } + + #[test] + fn zero_stub_length_no_stub() { + let nodes = vec![]; + let path_stub = route_orthogonal(&nodes, (100.0, 0.0), (100.0, 200.0), 20.0, 0.0); + // With zero stub, should be direct vertical + assert_eq!(path_stub.len(), 2); + } + + #[test] + fn simplify_removes_collinear_points() { + let path = vec![ + (0.0, 0.0), + (100.0, 0.0), + (200.0, 0.0), // collinear with prev two + (200.0, 100.0), + ]; + let simplified = simplify_path(path); + assert_eq!(simplified.len(), 3, "should remove middle collinear point"); + assert!((simplified[0].0 - 0.0).abs() < 0.01); + assert!((simplified[1].0 - 200.0).abs() < 0.01); + assert!((simplified[2].1 - 100.0).abs() < 0.01); + } + + #[test] + fn simplify_preserves_corners() { + let path = vec![ + (0.0, 0.0), + (100.0, 0.0), + (100.0, 100.0), // corner, not collinear + (200.0, 100.0), + ]; + let simplified = simplify_path(path.clone()); + assert_eq!(simplified.len(), 4, "should preserve all corner points"); + } + + #[test] + fn batch_routes_multiple_edges() { + let nodes = vec![ + make_node("A", 0.0, 0.0, 80.0, 40.0), + make_node("B", 0.0, 200.0, 80.0, 40.0), + ]; + let edges = vec![((40.0, 40.0), (40.0, 200.0)), ((60.0, 40.0), (60.0, 200.0))]; + let paths = route_orthogonal_batch(&nodes, &edges, 20.0, 10.0, 4.0); + assert_eq!(paths.len(), 2); + for path in &paths { + assert!(path.len() >= 2, "each path should have at least 2 points"); + assert_orthogonal(path); + } + } + + #[test] + fn nudge_separates_overlapping_horizontal_segments() { + // Two paths with identical horizontal segments + let mut paths = vec![ + vec![(0.0, 50.0), (100.0, 50.0), (100.0, 100.0)], + vec![(0.0, 50.0), (100.0, 50.0), (100.0, 200.0)], + ]; + nudge_parallel_segments(&mut paths, 4.0); + + // The horizontal segments (y=50) should now have different y values + let y0 = paths[0][0].1; + let y1 = paths[1][0].1; + assert!( + (y0 - y1).abs() > 1.0, + "overlapping segments should be nudged apart: y0={y0}, y1={y1}" + ); + } + + #[test] + fn nudge_separates_overlapping_vertical_segments() { + // Two paths with identical vertical segments + let mut paths = vec![ + vec![(50.0, 0.0), (50.0, 100.0), (100.0, 100.0)], + vec![(50.0, 0.0), (50.0, 100.0), (200.0, 100.0)], + ]; + nudge_parallel_segments(&mut paths, 4.0); + + // The vertical segments (x=50) should now have different x values + let x0 = paths[0][0].0; + let x1 = paths[1][0].0; + assert!( + (x0 - x1).abs() > 1.0, + "overlapping vertical segments should be nudged apart: x0={x0}, x1={x1}" + ); + } + + #[test] + fn routes_avoid_obstacles_with_clearance() { + // Place a large obstacle and route around it + let nodes = vec![make_node("Wall", 80.0, 80.0, 40.0, 40.0)]; + let path = route_orthogonal(&nodes, (100.0, 50.0), (100.0, 150.0), 20.0, 10.0); + + assert_orthogonal(&path); + assert_no_obstacle_penetration(&path, &nodes); + + // Check that path waypoints maintain clearance from obstacle + let obs = Rect { + x1: 80.0 - OBSTACLE_PADDING, + y1: 80.0 - OBSTACLE_PADDING, + x2: 120.0 + OBSTACLE_PADDING, + y2: 120.0 + OBSTACLE_PADDING, + }; + for &(x, y) in &path[1..path.len() - 1] { + // Interior waypoints should not be on the obstacle boundary + let on_boundary = (x - obs.x1).abs() < 0.01 + || (x - obs.x2).abs() < 0.01 + || (y - obs.y1).abs() < 0.01 + || (y - obs.y2).abs() < 0.01; + let inside = obs.contains_strict(x, y); + assert!(!inside, "waypoint ({x},{y}) is inside obstacle"); + // Waypoints right on the boundary are acceptable but ideally + // they should be offset by WAYPOINT_MARGIN. We don't enforce + // this strictly since the source/target may be near the boundary. + let _ = on_boundary; } } + + #[test] + fn dense_graph_all_segments_orthogonal() { + // A dense graph scenario with many obstacles + let nodes = vec![ + make_node("A", 0.0, 0.0, 60.0, 30.0), + make_node("B", 100.0, 0.0, 60.0, 30.0), + make_node("C", 200.0, 0.0, 60.0, 30.0), + make_node("D", 0.0, 80.0, 60.0, 30.0), + make_node("E", 100.0, 80.0, 60.0, 30.0), + make_node("F", 200.0, 80.0, 60.0, 30.0), + make_node("G", 0.0, 160.0, 60.0, 30.0), + make_node("H", 100.0, 160.0, 60.0, 30.0), + make_node("I", 200.0, 160.0, 60.0, 30.0), + ]; + + // Route across the dense grid + let path = route_orthogonal(&nodes, (30.0, 30.0), (230.0, 160.0), 20.0, 10.0); + assert_orthogonal(&path); + assert!(path.len() >= 2); + } + + #[test] + fn batch_route_dense_graph() { + let nodes = vec![ + make_node("A", 0.0, 0.0, 80.0, 40.0), + make_node("B", 200.0, 0.0, 80.0, 40.0), + make_node("C", 0.0, 120.0, 80.0, 40.0), + make_node("D", 200.0, 120.0, 80.0, 40.0), + ]; + let edges = vec![ + ((80.0, 20.0), (200.0, 20.0)), + ((80.0, 30.0), (200.0, 30.0)), + ((40.0, 40.0), (40.0, 120.0)), + ((40.0, 40.0), (240.0, 120.0)), + ]; + let paths = route_orthogonal_batch(&nodes, &edges, 20.0, 10.0, 4.0); + assert_eq!(paths.len(), 4); + for (i, path) in paths.iter().enumerate() { + assert!(path.len() >= 2, "path {i} too short: {} points", path.len()); + assert_orthogonal(path); + } + } + + #[test] + fn grid_key_round_trip_precision() { + // Verify that grid_key rounding doesn't lose significant precision + let coords = [(123.456, 789.012), (0.005, 0.005), (99.999, 100.001)]; + for (x, y) in coords { + let (kx, ky) = grid_key(x, y); + let rx = kx as f64 / 100.0; + let ry = ky as f64 / 100.0; + assert!((rx - x).abs() < 0.01, "x round-trip: {x} -> {kx} -> {rx}"); + assert!((ry - y).abs() < 0.01, "y round-trip: {y} -> {ky} -> {ry}"); + } + } + + #[test] + fn manhattan_heuristic() { + assert!((manhattan(0.0, 0.0, 3.0, 4.0) - 7.0).abs() < 0.001); + assert!((manhattan(1.0, 1.0, 1.0, 1.0) - 0.0).abs() < 0.001); + assert!((manhattan(-1.0, -2.0, 3.0, 5.0) - 11.0).abs() < 0.001); + } + + #[test] + fn simplify_empty_and_single() { + assert_eq!(simplify_path(vec![]).len(), 0); + assert_eq!(simplify_path(vec![(1.0, 2.0)]).len(), 1); + assert_eq!(simplify_path(vec![(1.0, 2.0), (3.0, 4.0)]).len(), 2); + } + + #[test] + fn no_nudge_for_single_path() { + let mut paths = vec![vec![(0.0, 0.0), (100.0, 0.0)]]; + let orig = paths.clone(); + nudge_parallel_segments(&mut paths, 4.0); + // Single path should not be modified + assert_eq!(paths, orig); + } + + #[test] + fn no_nudge_for_non_overlapping_segments() { + let mut paths = vec![ + vec![(0.0, 50.0), (50.0, 50.0)], + vec![(100.0, 50.0), (200.0, 50.0)], + ]; + let orig = paths.clone(); + nudge_parallel_segments(&mut paths, 4.0); + // Non-overlapping segments on same line should not be nudged + assert_eq!(paths, orig); + } } diff --git a/rivet-cli/src/render/artifacts.rs b/rivet-cli/src/render/artifacts.rs index 5a31832..77d00ab 100644 --- a/rivet-cli/src/render/artifacts.rs +++ b/rivet-cli/src/render/artifacts.rs @@ -368,8 +368,19 @@ pub(crate) fn render_artifact_detail(ctx: &RenderContext, id: &str) -> RenderRes String::new() }; + // oEmbed discovery tag — allows Notion/Confluence to auto-discover the embed + let oembed_discovery = format!( + r#""#, + port = ctx.context.port, + encoded_url = urlencoding::encode(&format!( + "http://localhost:{}/artifacts/{}", + ctx.context.port, artifact.id + )), + title = html_escape(&format!("{}: {}", artifact.id, artifact.title)), + ); + let mut html = format!( - "

{}{}

{}

", + "{oembed_discovery}

{}{}

{}

", html_escape(&artifact.id), source_link, badge_for_type(&artifact.artifact_type) diff --git a/rivet-cli/src/serve/api.rs b/rivet-cli/src/serve/api.rs new file mode 100644 index 0000000..4a46c4c --- /dev/null +++ b/rivet-cli/src/serve/api.rs @@ -0,0 +1,593 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use axum::Json; +use axum::extract::{Query, State}; +use axum::response::IntoResponse; +use serde::{Deserialize, Serialize}; + +use rivet_core::coverage::compute_coverage; +use rivet_core::schema::Severity; + +use super::SharedState; + +// ── Health ────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + project: String, + version: &'static str, + artifacts: usize, + uptime_seconds: u64, +} + +pub(crate) async fn health(State(state): State) -> impl IntoResponse { + let guard = state.read().await; + Json(HealthResponse { + status: "ok", + project: guard.context.project_name.clone(), + version: env!("CARGO_PKG_VERSION"), + artifacts: guard.store.len(), + uptime_seconds: guard.started_at.elapsed().as_secs(), + }) +} + +// ── oEmbed ────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub(crate) struct OembedParams { + url: String, + #[serde(default)] + format: Option, + #[serde(default)] + maxwidth: Option, + #[serde(default)] + maxheight: Option, +} + +#[derive(Serialize)] +struct OembedResponse { + version: &'static str, + r#type: &'static str, + title: String, + provider_name: &'static str, + provider_url: String, + width: u32, + height: u32, + html: String, +} + +pub(crate) async fn oembed( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + // Reject XML format + if params.format.as_deref() == Some("xml") { + return ( + axum::http::StatusCode::NOT_IMPLEMENTED, + Json(serde_json::json!({"error": "XML format not supported"})), + ) + .into_response(); + } + + // Extract artifact ID from URL path: find "/artifacts/" and take the rest + let artifact_id = params + .url + .find("/artifacts/") + .map(|i| ¶ms.url[i + "/artifacts/".len()..]) + .map(|s| s.split('/').next().unwrap_or(s)) + .map(|s| s.split('?').next().unwrap_or(s)); + + let artifact_id = match artifact_id { + Some(id) if !id.is_empty() => id, + _ => { + return ( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "URL must match /artifacts/{id}"})), + ) + .into_response(); + } + }; + + // Look up artifact in local store and external stores + let guard = state.read().await; + let artifact = guard.store.get(artifact_id).or_else(|| { + guard + .externals + .iter() + .find_map(|ext| ext.store.get(artifact_id)) + }); + + let artifact = match artifact { + Some(a) => a, + None => { + return ( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "artifact not found"})), + ) + .into_response(); + } + }; + + // Derive provider URL from the incoming url param (scheme + host + port) + let provider_url = extract_base_url(¶ms.url) + .unwrap_or_else(|| format!("http://localhost:{}", guard.context.port)); + + // Dimension clamping (oEmbed spec: maxwidth/maxheight are upper bounds) + let width = params.maxwidth.map_or(600, |mw| mw.min(600)); + let height = params.maxheight.map_or(400, |mh| mh.min(400)); + + let title = format!("{}: {}", artifact.id, artifact.title); + let iframe_src = format!("{provider_url}/embed/artifacts/{}", artifact.id); + let html = format!( + "" + ); + + Json(OembedResponse { + version: "1.0", + r#type: "rich", + title, + provider_name: "Rivet", + provider_url, + width, + height, + html, + }) + .into_response() +} + +/// Extract "http://host:port" from a full URL string. +fn extract_base_url(url: &str) -> Option { + let after_scheme = url.find("://").map(|i| i + 3)?; + let host_end = url[after_scheme..].find('/').map(|i| after_scheme + i)?; + Some(url[..host_end].to_string()) +} + +// ── Stats ─────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct StatsResponse { + total_artifacts: usize, + by_type: BTreeMap, + by_status: BTreeMap, + validation: ValidationStats, + coverage: Vec, + by_origin: BTreeMap, +} + +#[derive(Serialize)] +struct ValidationStats { + error: usize, + warning: usize, + info: usize, + clean: usize, +} + +#[derive(Serialize)] +struct CoverageStats { + rule: String, + description: String, + source_type: String, + link_type: String, + covered: usize, + total: usize, + percentage: f64, +} + +pub(crate) async fn stats(State(state): State) -> impl IntoResponse { + let guard = state.read().await; + + // by_type: include all schema types (even zero-count) + any types in store + let mut by_type = BTreeMap::new(); + for type_name in guard.schema.artifact_types.keys() { + by_type.insert(type_name.clone(), 0usize); + } + for artifact in guard.store.iter() { + *by_type.entry(artifact.artifact_type.clone()).or_default() += 1; + } + let local_count: usize = by_type.values().sum(); + + // external artifact counts + let mut by_origin = BTreeMap::new(); + by_origin.insert("local".to_string(), local_count); + for ext in &guard.externals { + let ext_count = ext.store.len(); + by_origin.insert(format!("external:{}", ext.prefix), ext_count); + for artifact in ext.store.iter() { + *by_type.entry(artifact.artifact_type.clone()).or_default() += 1; + } + } + + let total_artifacts: usize = by_type.values().sum(); + + // by_status + let mut by_status = BTreeMap::new(); + for artifact in guard.store.iter() { + let key = artifact.status.as_deref().unwrap_or("unset").to_string(); + *by_status.entry(key).or_default() += 1; + } + for ext in &guard.externals { + for artifact in ext.store.iter() { + let key = artifact.status.as_deref().unwrap_or("unset").to_string(); + *by_status.entry(key).or_default() += 1; + } + } + + // validation: count artifacts by worst diagnostic severity + let mut worst: BTreeMap = BTreeMap::new(); + for diag in &guard.cached_diagnostics { + if let Some(ref id) = diag.artifact_id { + let entry = worst.entry(id.clone()).or_insert(Severity::Info); + if severity_rank(diag.severity) > severity_rank(*entry) { + *entry = diag.severity; + } + } + } + let mut validation = ValidationStats { + error: 0, + warning: 0, + info: 0, + clean: 0, + }; + let all_ids: Vec = guard.store.iter().map(|a| a.id.clone()).collect(); + for id in &all_ids { + match worst.get(id) { + Some(Severity::Error) => validation.error += 1, + Some(Severity::Warning) => validation.warning += 1, + Some(Severity::Info) => validation.info += 1, + None => validation.clean += 1, + } + } + // External artifacts have no local diagnostics — count as clean + let ext_count: usize = guard.externals.iter().map(|e| e.store.len()).sum(); + validation.clean += ext_count; + + // coverage + let report = compute_coverage(&guard.store, &guard.schema, &guard.graph); + let coverage: Vec = report + .entries + .iter() + .map(|e| CoverageStats { + rule: e.rule_name.clone(), + description: e.description.clone(), + source_type: e.source_type.clone(), + link_type: e.link_type.clone(), + covered: e.covered, + total: e.total, + percentage: e.percentage(), + }) + .collect(); + + Json(StatsResponse { + total_artifacts, + by_type, + by_status, + validation, + coverage, + by_origin, + }) +} + +fn severity_rank(s: Severity) -> u8 { + match s { + Severity::Info => 1, + Severity::Warning => 2, + Severity::Error => 3, + } +} + +// ── Shared helpers ────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct ApiArtifact { + id: String, + title: String, + r#type: String, + status: Option, + origin: String, + links_out: usize, + links_in: usize, + source_file: Option, +} + +fn resolve_source_file( + artifact: &rivet_core::model::Artifact, + project_path: &Path, +) -> Option { + artifact.source_file.as_ref().and_then(|p| { + p.strip_prefix(project_path) + .ok() + .or(Some(p.as_path())) + .map(|rel| rel.display().to_string()) + }) +} + +fn to_api_artifact( + artifact: &rivet_core::model::Artifact, + origin: &str, + state: &super::AppState, +) -> ApiArtifact { + ApiArtifact { + id: artifact.id.clone(), + title: artifact.title.clone(), + r#type: artifact.artifact_type.clone(), + status: artifact.status.clone(), + origin: origin.to_string(), + links_out: state.graph.links_from(&artifact.id).len(), + links_in: state.graph.backlinks_to(&artifact.id).len(), + source_file: resolve_source_file(artifact, &state.project_path_buf), + } +} + +// ── Artifacts ─────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub(crate) struct ArtifactsParams { + #[serde(rename = "type")] + artifact_type: Option, + status: Option, + origin: Option, + q: Option, + #[serde(default = "default_limit")] + limit: u32, + #[serde(default)] + offset: u32, +} + +fn default_limit() -> u32 { + 100 +} + +#[derive(Serialize)] +struct ArtifactsResponse { + total: usize, + artifacts: Vec, +} + +pub(crate) async fn artifacts( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let guard = state.read().await; + let limit = params.limit.min(1000) as usize; + let offset = params.offset as usize; + + let include_externals = params + .origin + .as_deref() + .is_some_and(|o| o == "all" || o.starts_with("external:")); + + let mut results: Vec = Vec::new(); + + // Local artifacts (default scope) + let include_local = params + .origin + .as_deref() + .is_none_or(|o| o == "all" || o == "local"); + if include_local { + for artifact in guard.store.iter() { + if matches_filters(artifact, ¶ms) { + results.push(to_api_artifact(artifact, "local", &guard)); + } + } + } + + // External artifacts (only when explicitly requested) + if include_externals { + for ext in &guard.externals { + let ext_origin = format!("external:{}", ext.prefix); + let origin_matches = params + .origin + .as_deref() + .is_some_and(|o| o == "all" || o == ext_origin); + if origin_matches { + for artifact in ext.store.iter() { + if matches_filters(artifact, ¶ms) { + results.push(ApiArtifact { + id: artifact.id.clone(), + title: artifact.title.clone(), + r#type: artifact.artifact_type.clone(), + status: artifact.status.clone(), + origin: ext_origin.clone(), + links_out: 0, + links_in: 0, + source_file: resolve_source_file(artifact, &guard.project_path_buf), + }); + } + } + } + } + } + + let total = results.len(); + let page: Vec = results.into_iter().skip(offset).take(limit).collect(); + + Json(ArtifactsResponse { + total, + artifacts: page, + }) +} + +// ── Diagnostics ───────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub(crate) struct DiagnosticsParams { + severity: Option, + rule: Option, + artifact_id: Option, + origin: Option, + #[serde(default = "default_limit")] + limit: u32, + #[serde(default)] + offset: u32, +} + +#[derive(Serialize)] +struct ApiDiagnostic { + artifact_id: Option, + severity: String, + rule: String, + message: String, + origin: String, + source_file: Option, +} + +#[derive(Serialize)] +struct DiagnosticsResponse { + total: usize, + diagnostics: Vec, +} + +pub(crate) async fn diagnostics( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let guard = state.read().await; + let limit = params.limit.min(1000) as usize; + let offset = params.offset as usize; + + let mut results: Vec = Vec::new(); + + for diag in &guard.cached_diagnostics { + let severity_str = match diag.severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + }; + + if let Some(ref s) = params.severity { + if severity_str != s.as_str() { + continue; + } + } + if let Some(ref r) = params.rule { + if diag.rule != *r { + continue; + } + } + if let Some(ref id) = params.artifact_id { + if diag.artifact_id.as_deref() != Some(id.as_str()) { + continue; + } + } + + // Derive origin and source_file from artifact lookup + let (origin, source_file) = if let Some(ref art_id) = diag.artifact_id { + let origin = resolve_origin(art_id, &guard); + let sf = guard + .store + .get(art_id) + .or_else(|| guard.externals.iter().find_map(|ext| ext.store.get(art_id))) + .and_then(|a| resolve_source_file(a, &guard.project_path_buf)); + (origin, sf) + } else { + ("local".to_string(), None) + }; + + if let Some(ref o) = params.origin { + if origin != *o && o != "all" { + continue; + } + } + + results.push(ApiDiagnostic { + artifact_id: diag.artifact_id.clone(), + severity: severity_str.to_string(), + rule: diag.rule.clone(), + message: diag.message.clone(), + origin, + source_file, + }); + } + + let total = results.len(); + let page: Vec = results.into_iter().skip(offset).take(limit).collect(); + + Json(DiagnosticsResponse { + total, + diagnostics: page, + }) +} + +fn resolve_origin(id: &str, state: &super::AppState) -> String { + if state.store.contains(id) { + return "local".to_string(); + } + for ext in &state.externals { + if ext.store.contains(id) { + return format!("external:{}", ext.prefix); + } + } + "local".to_string() +} + +// ── Coverage ──────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct ApiCoverageRule { + rule: String, + description: String, + source_type: String, + link_type: String, + direction: String, + target_types: Vec, + covered: usize, + total: usize, + percentage: f64, + uncovered: Vec, +} + +#[derive(Serialize)] +struct CoverageResponse { + rules: Vec, +} + +pub(crate) async fn coverage(State(state): State) -> impl IntoResponse { + let guard = state.read().await; + let report = compute_coverage(&guard.store, &guard.schema, &guard.graph); + + let rules: Vec = report + .entries + .iter() + .map(|e| ApiCoverageRule { + rule: e.rule_name.clone(), + description: e.description.clone(), + source_type: e.source_type.clone(), + link_type: e.link_type.clone(), + direction: match e.direction { + rivet_core::coverage::CoverageDirection::Forward => "forward".to_string(), + rivet_core::coverage::CoverageDirection::Backward => "backward".to_string(), + }, + target_types: e.target_types.clone(), + covered: e.covered, + total: e.total, + percentage: e.percentage(), + uncovered: e.uncovered_ids.clone(), + }) + .collect(); + + Json(CoverageResponse { rules }) +} + +fn matches_filters(artifact: &rivet_core::model::Artifact, params: &ArtifactsParams) -> bool { + if let Some(ref t) = params.artifact_type { + if artifact.artifact_type != *t { + return false; + } + } + if let Some(ref s) = params.status { + let actual = artifact.status.as_deref().unwrap_or("unset"); + if actual != s.as_str() { + return false; + } + } + if let Some(ref q) = params.q { + let q_lower = q.to_lowercase(); + if !artifact.title.to_lowercase().contains(&q_lower) { + return false; + } + } + true +} diff --git a/rivet-cli/src/serve/mod.rs b/rivet-cli/src/serve/mod.rs index f64dd3f..e7b2ee6 100644 --- a/rivet-cli/src/serve/mod.rs +++ b/rivet-cli/src/serve/mod.rs @@ -7,6 +7,7 @@ use axum::extract::{Path, State}; use axum::response::IntoResponse; use axum::routing::{get, post}; use tokio::sync::RwLock; +use tower_http::cors::CorsLayer; /// HTMX bundled inline — no CDN dependency, works offline. const HTMX_JS: &str = include_str!("../../assets/htmx.min.js"); @@ -189,6 +190,8 @@ pub(crate) struct AppState { /// Cached validation diagnostics — computed once at load/reload time /// instead of on every page request. pub(crate) cached_diagnostics: Vec, + /// Server start time for uptime calculation. + pub(crate) started_at: std::time::Instant, } impl AppState { @@ -325,6 +328,7 @@ pub(crate) fn reload_state( doc_dirs, externals, cached_diagnostics, + started_at: std::time::Instant::now(), }) } @@ -516,6 +520,7 @@ pub async fn run( doc_dirs, externals: Vec::new(), cached_diagnostics, + started_at: std::time::Instant::now(), })); let app = Router::new() @@ -545,6 +550,18 @@ pub async fn run( .route("/traceability", get(views::traceability_view)) .route("/traceability/history", get(views::traceability_history)) .route("/api/links/{id}", get(api_artifact_links)) + .route("/oembed", get(api::oembed)) + .nest( + "/api/v1", + Router::new() + .route("/health", get(api::health)) + .route("/stats", get(api::stats)) + .route("/artifacts", get(api::artifacts)) + .route("/diagnostics", get(api::diagnostics)) + .route("/coverage", get(api::coverage)) + .layer(CorsLayer::permissive()) + .with_state(state.clone()), + ) .route("/wasm/{*path}", get(wasm_asset)) .route("/help", get(views::help_view)) .route("/help/docs", get(views::help_docs_list)) @@ -565,7 +582,7 @@ pub async fn run( |mut response: axum::response::Response| async move { response.headers_mut().insert( "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'" + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors *" .parse() .unwrap(), ); @@ -652,6 +669,7 @@ async fn wrap_full_page( && !is_htmx && (path != "/" || is_print || is_embed) && !path.starts_with("/api/") + && !path.starts_with("/oembed") && !path.starts_with("/assets/") && !path.starts_with("/wasm/") && !path.starts_with("/source-raw/") @@ -881,12 +899,13 @@ async fn reload_handler( State(state): State, headers: axum::http::HeaderMap, ) -> impl IntoResponse { - let (project_path, schemas_dir, port) = { + let (project_path, schemas_dir, port, started_at) = { let guard = state.read().await; ( guard.project_path_buf.clone(), guard.schemas_dir.clone(), guard.context.port, + guard.started_at, ) }; @@ -894,6 +913,7 @@ async fn reload_handler( Ok(new_state) => { let mut guard = state.write().await; *guard = new_state; + guard.started_at = started_at; // Redirect back to wherever the user was (HTMX sends HX-Current-URL). // Extract the path portion from the full URL (e.g. "http://localhost:3001/documents/DOC-001" → "/documents/DOC-001"). @@ -985,6 +1005,7 @@ async fn docs_asset( ) } +mod api; #[allow(dead_code)] pub(crate) mod components; pub(crate) mod js; diff --git a/rivet-cli/tests/serve_integration.rs b/rivet-cli/tests/serve_integration.rs index 91651ad..1ac7feb 100644 --- a/rivet-cli/tests/serve_integration.rs +++ b/rivet-cli/tests/serve_integration.rs @@ -44,9 +44,10 @@ fn start_server() -> (Child, u16) { .spawn() .expect("failed to start rivet serve"); - // Wait for server to be ready (up to 15s for slow CI runners) + // Wait for server to be ready (up to 30s — 20 integration tests each + // spawn a server, so system resources can be tight under CI/coverage). let addr = format!("127.0.0.1:{port}"); - for _ in 0..150 { + for _ in 0..300 { if std::net::TcpStream::connect(&addr).is_ok() { return (child, port); } @@ -55,7 +56,7 @@ fn start_server() -> (Child, u16) { // Kill the child before panicking to avoid zombie processes. let _ = child.kill(); let _ = child.wait(); - panic!("server did not start within 15 seconds on port {port}"); + panic!("server did not start within 30 seconds on port {port}"); } /// Fetch a page via HTTP. If `htmx` is true, sends the HX-Request header @@ -63,9 +64,22 @@ fn start_server() -> (Child, u16) { fn fetch(port: u16, path: &str, htmx: bool) -> (u16, String, Vec<(String, String)>) { let _url = format!("http://127.0.0.1:{port}{path}"); - // Use a minimal HTTP/1.1 request via TcpStream + // Use a minimal HTTP/1.1 request via TcpStream. + // Retry connect in case the server briefly drops between the health check and this call. use std::io::{Read, Write}; - let mut stream = std::net::TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect"); + let addr = format!("127.0.0.1:{port}"); + let mut stream = None; + for _ in 0..10 { + match std::net::TcpStream::connect(&addr) { + Ok(s) => { + stream = Some(s); + break; + } + Err(_) => std::thread::sleep(Duration::from_millis(100)), + } + } + let mut stream = stream + .unwrap_or_else(|| std::net::TcpStream::connect(&addr).expect("connect after retries")); stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); let hx_header = if htmx { "HX-Request: true\r\n" } else { "" }; @@ -226,8 +240,11 @@ fn reload_returns_hx_location() { // Simulate reload with HX-Current-URL header use std::io::{Read, Write}; + // reload_state re-reads the entire project from disk, which can be + // significantly slower under CI coverage / proptest instrumentation. + // Use a generous timeout to avoid flaky failures. let mut stream = std::net::TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect"); - stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_read_timeout(Some(Duration::from_secs(30))).ok(); let request = format!( "POST /reload HTTP/1.1\r\n\ @@ -240,7 +257,9 @@ fn reload_returns_hx_location() { stream.write_all(request.as_bytes()).expect("write"); let mut response = Vec::new(); - stream.read_to_end(&mut response).ok(); + stream + .read_to_end(&mut response) + .expect("read reload response"); let response = String::from_utf8_lossy(&response).to_string(); // Should contain HX-Location header pointing to /results @@ -257,3 +276,363 @@ fn reload_returns_hx_location() { child.kill().ok(); child.wait().ok(); } + +#[test] +fn api_health_returns_json() { + let (mut child, port) = start_server(); + + let (status, body, headers) = fetch(port, "/api/v1/health", false); + + assert_eq!(status, 200, "GET /api/v1/health should return 200"); + + let has_json_ct = headers + .iter() + .any(|(k, v)| k.eq_ignore_ascii_case("content-type") && v.contains("application/json")); + assert!(has_json_ct, "health endpoint must return application/json"); + + let json: serde_json::Value = serde_json::from_str(&body) + .unwrap_or_else(|e| panic!("health response is not valid JSON: {e}\nbody: {body}")); + assert_eq!(json["status"], "ok"); + assert!(json["project"].is_string()); + assert!(json["version"].is_string()); + assert!(json["artifacts"].is_number()); + assert!(json["uptime_seconds"].is_number()); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_v1_cors_headers() { + let (mut child, port) = start_server(); + + let (status, _body, headers) = fetch(port, "/api/v1/health", false); + assert_eq!(status, 200); + + let has_cors = headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("access-control-allow-origin")); + assert!( + has_cors, + "API v1 endpoints must include CORS headers. Headers: {headers:?}" + ); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn oembed_valid_artifact() { + let (mut child, port) = start_server(); + + let raw_url = format!("http://127.0.0.1:{port}/artifacts/REQ-001"); + let url = urlencoding::encode(&raw_url); + let (status, body, _headers) = fetch(port, &format!("/oembed?url={url}&format=json"), false); + + assert_eq!(status, 200, "oEmbed with valid artifact should return 200"); + + let json: serde_json::Value = serde_json::from_str(&body) + .unwrap_or_else(|e| panic!("oEmbed response not valid JSON: {e}\nbody: {body}")); + assert_eq!(json["version"], "1.0"); + assert_eq!(json["type"], "rich"); + assert!(json["title"].as_str().unwrap().contains("REQ-001")); + assert!(json["html"].as_str().unwrap().contains("iframe")); + assert!( + json["html"] + .as_str() + .unwrap() + .contains("/embed/artifacts/REQ-001") + ); + assert!(json["width"].is_number()); + assert!(json["height"].is_number()); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn oembed_unknown_artifact_returns_404() { + let (mut child, port) = start_server(); + + let raw_url = format!("http://127.0.0.1:{port}/artifacts/NONEXISTENT-999"); + let url = urlencoding::encode(&raw_url); + let (status, _body, _headers) = fetch(port, &format!("/oembed?url={url}"), false); + + assert_eq!( + status, 404, + "oEmbed with unknown artifact should return 404" + ); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn oembed_non_artifact_url_returns_404() { + let (mut child, port) = start_server(); + + let raw_url = format!("http://127.0.0.1:{port}/coverage"); + let url = urlencoding::encode(&raw_url); + let (status, _body, _headers) = fetch(port, &format!("/oembed?url={url}"), false); + + assert_eq!( + status, 404, + "oEmbed with non-artifact URL should return 404" + ); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn oembed_xml_format_returns_501() { + let (mut child, port) = start_server(); + + let raw_url = format!("http://127.0.0.1:{port}/artifacts/REQ-001"); + let url = urlencoding::encode(&raw_url); + let (status, _body, _headers) = fetch(port, &format!("/oembed?url={url}&format=xml"), false); + + assert_eq!(status, 501, "oEmbed with format=xml should return 501"); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn oembed_maxwidth_clamps() { + let (mut child, port) = start_server(); + + let raw_url = format!("http://127.0.0.1:{port}/artifacts/REQ-001"); + let url = urlencoding::encode(&raw_url); + let (status, body, _headers) = fetch(port, &format!("/oembed?url={url}&maxwidth=300"), false); + + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!( + json["width"].as_u64().unwrap() <= 300, + "width should be clamped to maxwidth" + ); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_stats_response_shape() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/stats", false); + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body) + .unwrap_or_else(|e| panic!("stats not valid JSON: {e}\nbody: {body}")); + + assert!(json["total_artifacts"].is_number()); + assert!(json["by_type"].is_object()); + assert!(json["by_status"].is_object()); + assert!(json["validation"].is_object()); + assert!(json["coverage"].is_array()); + assert!(json["by_origin"].is_object()); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_artifacts_unfiltered() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/artifacts", false); + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(json["total"].as_u64().unwrap() > 0, "should have artifacts"); + assert!(json["artifacts"].is_array()); + + let first = &json["artifacts"][0]; + assert!(first["id"].is_string()); + assert!(first["title"].is_string()); + assert!(first["type"].is_string()); + assert!(first["origin"].is_string()); + assert!(first["links_out"].is_number()); + assert!(first["links_in"].is_number()); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_artifacts_filter_by_type() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/artifacts?type=requirement", false); + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + for art in json["artifacts"].as_array().unwrap() { + assert_eq!( + art["type"], "requirement", + "filtered artifacts must all be requirements" + ); + } + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_artifacts_pagination() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/artifacts?limit=5", false); + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let artifacts = json["artifacts"].as_array().unwrap(); + assert!( + artifacts.len() <= 5, + "limit=5 should return at most 5 artifacts, got {}", + artifacts.len() + ); + assert!(json["total"].as_u64().unwrap() >= artifacts.len() as u64); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_artifacts_search() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/artifacts?q=STPA", false); + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + for art in json["artifacts"].as_array().unwrap() { + let title = art["title"].as_str().unwrap().to_lowercase(); + assert!( + title.contains("stpa"), + "search results must contain 'stpa' in title, got: {title}" + ); + } + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_stats_total_matches_by_type_sum() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/stats", false); + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let total = json["total_artifacts"].as_u64().unwrap(); + let by_type_sum: u64 = json["by_type"] + .as_object() + .unwrap() + .values() + .map(|v| v.as_u64().unwrap()) + .sum(); + + assert_eq!( + total, by_type_sum, + "total_artifacts ({total}) must equal sum of by_type values ({by_type_sum})" + ); + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_diagnostics_response_shape() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/diagnostics", false); + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(json["total"].is_number()); + assert!(json["diagnostics"].is_array()); + + if let Some(first) = json["diagnostics"].as_array().and_then(|a| a.first()) { + assert!(first["severity"].is_string()); + assert!(first["rule"].is_string()); + assert!(first["message"].is_string()); + } + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_diagnostics_filter_severity() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/diagnostics?severity=error", false); + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + for diag in json["diagnostics"].as_array().unwrap() { + assert_eq!( + diag["severity"], "error", + "filtered diagnostics must all have error severity" + ); + } + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn api_coverage_response_shape() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/api/v1/coverage", false); + assert_eq!(status, 200); + + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(json["rules"].is_array()); + + if let Some(first) = json["rules"].as_array().and_then(|a| a.first()) { + assert!(first["rule"].is_string()); + assert!(first["source_type"].is_string()); + assert!(first["link_type"].is_string()); + assert!(first["direction"].is_string()); + assert!(first["target_types"].is_array()); + assert!(first["covered"].is_number()); + assert!(first["total"].is_number()); + assert!(first["uncovered"].is_array()); + + let pct = first["percentage"].as_f64().unwrap(); + assert!( + (0.0..=100.0).contains(&pct), + "percentage must be 0..100, got {pct}" + ); + } + + child.kill().ok(); + child.wait().ok(); +} + +#[test] +fn artifact_detail_has_oembed_discovery_link() { + let (mut child, port) = start_server(); + + let (status, body, _headers) = fetch(port, "/artifacts/REQ-001", false); + assert_eq!(status, 200); + + assert!( + body.contains("application/json+oembed"), + "artifact detail page must contain oEmbed discovery tag" + ); + assert!( + body.contains("/oembed?"), + "oEmbed discovery link must point to /oembed endpoint" + ); + + child.kill().ok(); + child.wait().ok(); +} diff --git a/tests/playwright/api.spec.ts b/tests/playwright/api.spec.ts new file mode 100644 index 0000000..5fc08c6 --- /dev/null +++ b/tests/playwright/api.spec.ts @@ -0,0 +1,423 @@ +import { test, expect } from "@playwright/test"; + +// ── Health ──────────────────────────────────────────────────────────────── + +test.describe("API v1: Health", () => { + test("returns valid JSON with expected fields", async ({ page }) => { + const resp = await page.request.get("/api/v1/health"); + expect(resp.status()).toBe(200); + expect(resp.headers()["content-type"]).toContain("application/json"); + + const json = await resp.json(); + expect(json.status).toBe("ok"); + expect(json.project).toBe("rivet"); + expect(typeof json.version).toBe("string"); + expect(json.version).toMatch(/^\d+\.\d+\.\d+/); + expect(json.artifacts).toBeGreaterThan(0); + expect(json.uptime_seconds).toBeGreaterThanOrEqual(0); + }); + + test("has CORS headers for cross-origin Grafana access", async ({ + page, + }) => { + const resp = await page.request.get("/api/v1/health"); + expect(resp.headers()["access-control-allow-origin"]).toBeDefined(); + }); +}); + +// ── Stats (Grafana gauge/pie panels) ────────────────────────────────────── + +test.describe("API v1: Stats — Grafana dashboard data", () => { + test("total_artifacts matches the dashboard artifact count", async ({ + page, + }) => { + const statsResp = await page.request.get("/api/v1/stats"); + const stats = await statsResp.json(); + + // Cross-check: the dashboard shows the same count + await page.goto("/"); + const badge = page.locator('a[href="/artifacts"] .nav-badge'); + const badgeText = await badge.textContent(); + const dashboardCount = parseInt(badgeText!.trim(), 10); + + // Stats total should include all local artifacts visible in the dashboard + expect(stats.total_artifacts).toBeGreaterThanOrEqual(dashboardCount); + }); + + test("by_type sums to total_artifacts", async ({ page }) => { + const resp = await page.request.get("/api/v1/stats"); + const stats = await resp.json(); + + const typeSum = Object.values(stats.by_type).reduce( + (a: number, b) => a + (b as number), + 0, + ); + expect(typeSum).toBe(stats.total_artifacts); + }); + + test("by_type includes all known types with counts", async ({ page }) => { + const resp = await page.request.get("/api/v1/stats"); + const stats = await resp.json(); + + // Must have requirement and feature types at minimum + expect(stats.by_type.requirement).toBeGreaterThan(0); + expect(stats.by_type.feature).toBeGreaterThan(0); + }); + + test("by_status groups are non-empty", async ({ page }) => { + const resp = await page.request.get("/api/v1/stats"); + const stats = await resp.json(); + + const statusSum = Object.values(stats.by_status).reduce( + (a: number, b) => a + (b as number), + 0, + ); + expect(statusSum).toBeGreaterThan(0); + }); + + test("validation counts cover all artifacts", async ({ page }) => { + const resp = await page.request.get("/api/v1/stats"); + const stats = await resp.json(); + const { error, warning, info, clean } = stats.validation; + + // Sum of validation buckets should equal total_artifacts + expect(error + warning + info + clean).toBe(stats.total_artifacts); + }); + + test("coverage rules have valid percentages", async ({ page }) => { + const resp = await page.request.get("/api/v1/stats"); + const stats = await resp.json(); + + expect(stats.coverage.length).toBeGreaterThan(0); + for (const rule of stats.coverage) { + expect(rule.percentage).toBeGreaterThanOrEqual(0); + expect(rule.percentage).toBeLessThanOrEqual(100); + expect(rule.covered).toBeLessThanOrEqual(rule.total); + } + }); + + test("by_origin includes local", async ({ page }) => { + const resp = await page.request.get("/api/v1/stats"); + const stats = await resp.json(); + + expect(stats.by_origin.local).toBeGreaterThan(0); + }); +}); + +// ── Artifacts (Grafana table panels) ────────────────────────────────────── + +test.describe("API v1: Artifacts — Grafana table data", () => { + test("returns artifacts with complete fields", async ({ page }) => { + const resp = await page.request.get("/api/v1/artifacts?limit=5"); + expect(resp.status()).toBe(200); + + const data = await resp.json(); + expect(data.total).toBeGreaterThan(0); + expect(data.artifacts.length).toBeLessThanOrEqual(5); + + const art = data.artifacts[0]; + expect(art.id).toBeTruthy(); + expect(art.title).toBeTruthy(); + expect(art.type).toBeTruthy(); + expect(art.origin).toBe("local"); + expect(typeof art.links_out).toBe("number"); + expect(typeof art.links_in).toBe("number"); + }); + + test("type filter returns only matching artifacts", async ({ page }) => { + const resp = await page.request.get("/api/v1/artifacts?type=requirement"); + const data = await resp.json(); + + expect(data.artifacts.length).toBeGreaterThan(0); + for (const art of data.artifacts) { + expect(art.type).toBe("requirement"); + } + }); + + test("search filter matches titles", async ({ page }) => { + const resp = await page.request.get("/api/v1/artifacts?q=STPA"); + const data = await resp.json(); + + for (const art of data.artifacts) { + expect(art.title.toLowerCase()).toContain("stpa"); + } + }); + + test("pagination works correctly", async ({ page }) => { + const page1 = await ( + await page.request.get("/api/v1/artifacts?limit=3&offset=0") + ).json(); + const page2 = await ( + await page.request.get("/api/v1/artifacts?limit=3&offset=3") + ).json(); + + // Same total across pages + expect(page1.total).toBe(page2.total); + + // Different artifacts on each page + if (page2.artifacts.length > 0) { + expect(page1.artifacts[0].id).not.toBe(page2.artifacts[0].id); + } + }); + + test("artifact detail page matches API data", async ({ page }) => { + // Get an artifact from the API + const resp = await page.request.get( + "/api/v1/artifacts?type=requirement&limit=1", + ); + const data = await resp.json(); + const apiArt = data.artifacts[0]; + + // Navigate to its detail page and verify the title matches + await page.goto(`/artifacts/${apiArt.id}`); + const pageTitle = await page.locator("h2").first().textContent(); + expect(pageTitle).toContain(apiArt.id); + }); +}); + +// ── Diagnostics (Grafana alert panels) ──────────────────────────────────── + +test.describe("API v1: Diagnostics", () => { + test("returns diagnostics with valid structure", async ({ page }) => { + const resp = await page.request.get("/api/v1/diagnostics"); + expect(resp.status()).toBe(200); + + const data = await resp.json(); + expect(typeof data.total).toBe("number"); + expect(Array.isArray(data.diagnostics)).toBe(true); + + if (data.diagnostics.length > 0) { + const diag = data.diagnostics[0]; + expect(["error", "warning", "info"]).toContain(diag.severity); + expect(diag.rule).toBeTruthy(); + expect(diag.message).toBeTruthy(); + expect(diag.origin).toBeTruthy(); + } + }); + + test("severity filter works", async ({ page }) => { + const resp = await page.request.get("/api/v1/diagnostics?severity=info"); + const data = await resp.json(); + + for (const diag of data.diagnostics) { + expect(diag.severity).toBe("info"); + } + }); + + test("diagnostics count matches validation page", async ({ page }) => { + const apiResp = await page.request.get("/api/v1/diagnostics"); + const apiData = await apiResp.json(); + + // Navigate to the validation page and check the count is consistent + await page.goto("/validate"); + const body = await page.locator("#content").textContent(); + + // If there are diagnostics, the validation page should show something + if (apiData.total > 0) { + // The page should contain diagnostic-related content + expect(body).toBeTruthy(); + } + }); +}); + +// ── Coverage (Grafana bar/gauge panels) ─────────────────────────────────── + +test.describe("API v1: Coverage — traceability rules", () => { + test("returns per-rule coverage with valid structure", async ({ page }) => { + const resp = await page.request.get("/api/v1/coverage"); + expect(resp.status()).toBe(200); + + const data = await resp.json(); + expect(data.rules.length).toBeGreaterThan(0); + + const rule = data.rules[0]; + expect(rule.rule).toBeTruthy(); + expect(rule.source_type).toBeTruthy(); + expect(rule.link_type).toBeTruthy(); + expect(["forward", "backward"]).toContain(rule.direction); + expect(Array.isArray(rule.target_types)).toBe(true); + expect(rule.percentage).toBeGreaterThanOrEqual(0); + expect(rule.percentage).toBeLessThanOrEqual(100); + expect(rule.covered).toBeLessThanOrEqual(rule.total); + expect(Array.isArray(rule.uncovered)).toBe(true); + expect(rule.uncovered.length).toBe(rule.total - rule.covered); + }); + + test("coverage data matches the coverage dashboard page", async ({ + page, + }) => { + const apiResp = await page.request.get("/api/v1/coverage"); + const apiData = await apiResp.json(); + + // The coverage page should list the same rules + await page.goto("/coverage"); + const body = await page.locator("#content").textContent(); + + // Each rule name from the API should appear on the coverage page + for (const rule of apiData.rules) { + expect(body).toContain(rule.rule); + } + }); +}); + +// ── oEmbed (Notion/Confluence embedding) ────────────────────────────────── + +test.describe("oEmbed Provider", () => { + test("returns valid oEmbed JSON for artifact URL", async ({ page }) => { + const artifactUrl = encodeURIComponent( + "http://localhost:3003/artifacts/REQ-001", + ); + const resp = await page.request.get( + `/oembed?url=${artifactUrl}&format=json`, + ); + expect(resp.status()).toBe(200); + expect(resp.headers()["content-type"]).toContain("application/json"); + + const oembed = await resp.json(); + expect(oembed.version).toBe("1.0"); + expect(oembed.type).toBe("rich"); + expect(oembed.title).toContain("REQ-001"); + expect(oembed.provider_name).toBe("Rivet"); + expect(oembed.width).toBeGreaterThan(0); + expect(oembed.height).toBeGreaterThan(0); + + // The HTML must be a valid iframe that a consumer can embed + expect(oembed.html).toContain(" { + // Fetch the oEmbed response + const artifactUrl = encodeURIComponent( + "http://localhost:3003/artifacts/REQ-001", + ); + const resp = await page.request.get(`/oembed?url=${artifactUrl}`); + const oembed = await resp.json(); + + // Extract the iframe src + const srcMatch = oembed.html.match(/src="([^"]+)"/); + expect(srcMatch).toBeTruthy(); + const iframeSrc = srcMatch![1]; + + // The embed URL should return 200 with an HTML page + // (embed layout loads content via HTMX on the client) + const embedResp = await page.request.get( + iframeSrc.replace("http://localhost:3003", ""), + ); + expect(embedResp.status()).toBe(200); + const html = await embedResp.text(); + expect(html).toContain(" { + const artifactUrl = encodeURIComponent( + "http://localhost:3003/artifacts/REQ-001", + ); + const resp = await page.request.get( + `/oembed?url=${artifactUrl}&maxwidth=300&maxheight=200`, + ); + const oembed = await resp.json(); + + expect(oembed.width).toBeLessThanOrEqual(300); + expect(oembed.height).toBeLessThanOrEqual(200); + }); + + test("unknown artifact returns 404", async ({ page }) => { + const url = encodeURIComponent( + "http://localhost:3003/artifacts/DOES-NOT-EXIST", + ); + const resp = await page.request.get(`/oembed?url=${url}`); + expect(resp.status()).toBe(404); + }); + + test("non-artifact URL returns 404", async ({ page }) => { + const url = encodeURIComponent("http://localhost:3003/coverage"); + const resp = await page.request.get(`/oembed?url=${url}`); + expect(resp.status()).toBe(404); + }); + + test("format=xml returns 501", async ({ page }) => { + const url = encodeURIComponent( + "http://localhost:3003/artifacts/REQ-001", + ); + const resp = await page.request.get(`/oembed?url=${url}&format=xml`); + expect(resp.status()).toBe(501); + }); + + test("artifact detail page has oEmbed discovery tag", async ({ page }) => { + await page.goto("/artifacts/REQ-001"); + const html = await page.content(); + + expect(html).toContain("application/json+oembed"); + expect(html).toContain("/oembed?"); + }); +}); + +// ── CORS & Security ────────────────────────────────────────────────────── + +test.describe("API v1: CORS for Grafana", () => { + test("API v1 endpoints have CORS headers", async ({ page }) => { + for (const path of [ + "/api/v1/health", + "/api/v1/stats", + "/api/v1/artifacts", + "/api/v1/diagnostics", + "/api/v1/coverage", + ]) { + const resp = await page.request.get(path); + expect(resp.headers()["access-control-allow-origin"]).toBeDefined(); + } + }); + + test("non-API routes do NOT have CORS", async ({ page }) => { + const resp = await page.request.get("/"); + expect(resp.headers()["access-control-allow-origin"]).toBeUndefined(); + }); + + test("CSP frame-ancestors allows iframe embedding", async ({ page }) => { + const resp = await page.request.get("/"); + const csp = resp.headers()["content-security-policy"]; + expect(csp).toContain("frame-ancestors"); + }); +}); + +// ── Cross-checks: API vs Dashboard consistency ─────────────────────────── + +test.describe("API vs Dashboard consistency", () => { + test("API requirement count matches dashboard filter", async ({ page }) => { + // Get requirement count from API + const resp = await page.request.get("/api/v1/artifacts?type=requirement"); + const data = await resp.json(); + const apiCount = data.total; + + // Get count from stats API + const statsResp = await page.request.get("/api/v1/stats"); + const stats = await statsResp.json(); + + expect(apiCount).toBe(stats.by_type.requirement); + }); + + test("API stats coverage matches /api/v1/coverage details", async ({ + page, + }) => { + const statsResp = await page.request.get("/api/v1/stats"); + const stats = await statsResp.json(); + + const covResp = await page.request.get("/api/v1/coverage"); + const coverage = await covResp.json(); + + // Same number of rules + expect(stats.coverage.length).toBe(coverage.rules.length); + + // Same percentages + for (let i = 0; i < stats.coverage.length; i++) { + expect(stats.coverage[i].rule).toBe(coverage.rules[i].rule); + expect(stats.coverage[i].percentage).toBe(coverage.rules[i].percentage); + } + }); +});