From 36c45888b8883153563da9d84a82329db01a82d5 Mon Sep 17 00:00:00 2001 From: Jairo Fernandez Date: Thu, 21 May 2026 01:57:52 -0500 Subject: [PATCH 1/4] feat: add knock-mock crate for mock HTTP server functionality - Introduced `Cargo.toml` for the `knock-mock` crate with dependencies including `axum`, `tokio`, `serde`, and others. - Implemented authentication schemes in `auth.rs`, supporting Bearer, API Key, Basic, and JWT methods. - Created a `faker.rs` module for generating mock data based on defined schemas. - Developed the core server functionality in `server.rs`, including request handling, logging, and response rendering. - Defined the mock server specification in `spec.rs`, allowing for route and authentication configuration. - Added smoke tests in `server_smoke.rs` to validate server behavior, including route responses, authentication, and schema rendering. --- Cargo.toml | 2 +- apps/knock-app/src-tauri/Cargo.toml | 2 + apps/knock-app/src-tauri/src/lib.rs | 10 + apps/knock-app/src-tauri/src/mock_cmd.rs | 368 +++++++++++++++ apps/knock-app/src/App.tsx | 25 +- apps/knock-app/src/MockView.tsx | 574 +++++++++++++++++++++++ apps/knock-app/src/Rail.tsx | 11 +- apps/knock-app/src/styles.css | 313 ++++++++++++ crates/knock-cli/Cargo.toml | 3 + crates/knock-cli/src/main.rs | 60 +++ crates/knock-core/src/lib.rs | 5 +- crates/knock-core/src/model.rs | 76 +++ crates/knock-core/src/parser.rs | 7 +- crates/knock-core/src/workspace.rs | 10 + crates/knock-mock-adapter/Cargo.toml | 14 + crates/knock-mock-adapter/src/lib.rs | 378 +++++++++++++++ crates/knock-mock/Cargo.toml | 21 + crates/knock-mock/src/auth.rs | 299 ++++++++++++ crates/knock-mock/src/faker.rs | 151 ++++++ crates/knock-mock/src/lib.rs | 14 + crates/knock-mock/src/server.rs | 322 +++++++++++++ crates/knock-mock/src/spec.rs | 86 ++++ crates/knock-mock/tests/server_smoke.rs | 153 ++++++ 23 files changed, 2894 insertions(+), 10 deletions(-) create mode 100644 apps/knock-app/src-tauri/src/mock_cmd.rs create mode 100644 apps/knock-app/src/MockView.tsx create mode 100644 crates/knock-mock-adapter/Cargo.toml create mode 100644 crates/knock-mock-adapter/src/lib.rs create mode 100644 crates/knock-mock/Cargo.toml create mode 100644 crates/knock-mock/src/auth.rs create mode 100644 crates/knock-mock/src/faker.rs create mode 100644 crates/knock-mock/src/lib.rs create mode 100644 crates/knock-mock/src/server.rs create mode 100644 crates/knock-mock/src/spec.rs create mode 100644 crates/knock-mock/tests/server_smoke.rs diff --git a/Cargo.toml b/Cargo.toml index fdda5d0..4ec1533 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/knock-core", "crates/knock-cli", "apps/knock-app/src-tauri"] +members = ["crates/knock-core", "crates/knock-cli", "crates/knock-mock", "crates/knock-mock-adapter", "apps/knock-app/src-tauri"] [workspace.package] version = "0.0.11" diff --git a/apps/knock-app/src-tauri/Cargo.toml b/apps/knock-app/src-tauri/Cargo.toml index ae7a74e..929868a 100644 --- a/apps/knock-app/src-tauri/Cargo.toml +++ b/apps/knock-app/src-tauri/Cargo.toml @@ -13,6 +13,8 @@ tauri-build = { version = "2", features = [] } [dependencies] knock-core = { path = "../../../crates/knock-core" } +knock-mock = { path = "../../../crates/knock-mock" } +knock-mock-adapter = { path = "../../../crates/knock-mock-adapter" } tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" serde = { workspace = true } diff --git a/apps/knock-app/src-tauri/src/lib.rs b/apps/knock-app/src-tauri/src/lib.rs index 28ba87a..8b608a0 100644 --- a/apps/knock-app/src-tauri/src/lib.rs +++ b/apps/knock-app/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ pub mod commands; mod kubeconfig_cmd; +mod mock_cmd; mod openapi_cmd; mod recents; mod terminal_cmd; @@ -9,6 +10,7 @@ use std::time::{Duration, SystemTime}; use tauri::{Manager, RunEvent}; use kubeconfig_cmd::TempCache; +use mock_cmd::MockState; use terminal_cmd::TerminalSessions; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -17,6 +19,7 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .manage(TempCache::default()) .manage(TerminalSessions::default()) + .manage(MockState::default()) .setup(|app| { sweep_stale_tmp_files(); #[cfg(target_os = "macos")] @@ -106,6 +109,13 @@ pub fn run() { updates_cmd::download_app_update, updates_cmd::install_app_update, updates_cmd::run_app_installer, + mock_cmd::mock_status, + mock_cmd::mock_preview, + mock_cmd::mock_start, + mock_cmd::mock_stop, + mock_cmd::mock_read_response, + mock_cmd::mock_save_response, + mock_cmd::mock_clear_response, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/apps/knock-app/src-tauri/src/mock_cmd.rs b/apps/knock-app/src-tauri/src/mock_cmd.rs new file mode 100644 index 0000000..bda404a --- /dev/null +++ b/apps/knock-app/src-tauri/src/mock_cmd.rs @@ -0,0 +1,368 @@ +use indexmap::IndexMap; +use knock_core::{parser, MockBody, RequestMock, Workspace}; +use knock_mock::{serve_with_logger, Handle, LogEntry}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tauri::{Emitter, State}; + +pub const LOG_EVENT: &str = "mock://log"; +pub const STATUS_EVENT: &str = "mock://status"; + +#[derive(Default)] +pub struct MockState { + inner: Mutex>, +} + +struct RunningMock { + workspace_root: PathBuf, + addr: SocketAddr, + started_at: u128, + route_count: usize, + handle: Option, +} + +#[derive(Serialize, Clone)] +pub struct MockStatus { + pub running: bool, + pub workspace_root: Option, + pub addr: Option, + pub route_count: Option, + pub started_at: Option, +} + +#[derive(Serialize)] +pub struct MockRouteSummary { + pub method: String, + pub path: String, + pub auth: Option, + pub status: u16, + pub request_rel: String, + /// "inline" | "sibling" | "openapi" | "stub" | "empty" + pub source: String, +} + +#[derive(Serialize)] +pub struct MockSpecPreview { + pub routes: Vec, + pub auth_schemes: Vec, + pub default_delay_ms: Option, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct MockResponseEdit { + #[serde(default)] + pub status: Option, + #[serde(default)] + pub headers: std::collections::BTreeMap, + #[serde(default)] + pub auth: Option, + #[serde(default)] + pub delay_ms: Option, + /// raw JSON string the user typed + #[serde(default)] + pub body_json: Option, + /// raw text body (used when body_json is empty) + #[serde(default)] + pub body_text: Option, +} + +#[derive(Serialize)] +pub struct MockResponseRead { + pub origin: String, // "inline" | "sibling" | "none" + pub edit: MockResponseEdit, +} + +#[tauri::command] +pub fn mock_status(state: State<'_, MockState>) -> MockStatus { + let g = state.inner.lock().unwrap(); + match &*g { + Some(r) => MockStatus { + running: true, + workspace_root: Some(r.workspace_root.display().to_string()), + addr: Some(r.addr.to_string()), + route_count: Some(r.route_count), + started_at: Some(r.started_at), + }, + None => MockStatus { + running: false, + workspace_root: None, + addr: None, + route_count: None, + started_at: None, + }, + } +} + +#[tauri::command] +pub fn mock_preview(workspace_root: String) -> Result { + let ws = Workspace::discover(&PathBuf::from(&workspace_root)).map_err(|e| e.to_string())?; + let out = knock_mock_adapter::build_with_origins(&ws).map_err(|e| e.to_string())?; + let routes = out + .spec + .routes + .iter() + .zip(out.origins.iter()) + .map(|(r, o)| MockRouteSummary { + method: r.method.clone(), + path: r.path.clone(), + auth: r.auth.clone(), + status: r.response.status, + request_rel: o.request_rel.clone(), + source: source_str(o.source).into(), + }) + .collect(); + let auth_schemes = out.spec.auth.iter().map(|a| a.name().to_string()).collect(); + Ok(MockSpecPreview { + routes, + auth_schemes, + default_delay_ms: out.spec.default_delay_ms, + }) +} + +fn source_str(s: knock_mock_adapter::BodySource) -> &'static str { + use knock_mock_adapter::BodySource as B; + match s { + B::Inline => "inline", + B::Sibling => "sibling", + B::OpenApi => "openapi", + B::Stub => "stub", + B::Empty => "empty", + } +} + +#[tauri::command] +pub fn mock_read_response( + workspace_root: String, + request_rel: String, +) -> Result { + let ws = Workspace::discover(&PathBuf::from(&workspace_root)).map_err(|e| e.to_string())?; + let req_path = ws.requests_dir().join(&request_rel); + let req = parser::parse_request(&req_path) + .map_err(|e| format!("parse {}: {e}", req_path.display()))?; + if let Some(m) = req.mock { + return Ok(MockResponseRead { + origin: "inline".into(), + edit: edit_from_mock(&m), + }); + } + let sib = sibling_path(&ws, &request_rel); + if sib.is_file() { + let m = parser::parse_request_mock(&sib) + .map_err(|e| format!("parse {}: {e}", sib.display()))?; + return Ok(MockResponseRead { + origin: "sibling".into(), + edit: edit_from_mock(&m), + }); + } + Ok(MockResponseRead { + origin: "none".into(), + edit: MockResponseEdit::default(), + }) +} + +#[tauri::command] +pub fn mock_save_response( + workspace_root: String, + request_rel: String, + edit: MockResponseEdit, +) -> Result<(), String> { + let ws = Workspace::discover(&PathBuf::from(&workspace_root)).map_err(|e| e.to_string())?; + let mock = mock_from_edit(&edit).map_err(|e| e.to_string())?; + let sib = sibling_path(&ws, &request_rel); + if let Some(parent) = sib.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let toml_str = toml::to_string_pretty(&mock).map_err(|e| e.to_string())?; + std::fs::write(&sib, toml_str).map_err(|e| format!("write {}: {e}", sib.display()))?; + Ok(()) +} + +#[tauri::command] +pub fn mock_clear_response(workspace_root: String, request_rel: String) -> Result<(), String> { + let ws = Workspace::discover(&PathBuf::from(&workspace_root)).map_err(|e| e.to_string())?; + let sib = sibling_path(&ws, &request_rel); + if sib.is_file() { + std::fs::remove_file(&sib).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn sibling_path(ws: &Workspace, rel: &str) -> PathBuf { + let stem = Path::new(rel).with_extension(""); + ws.root.join("mocks").join(stem).with_extension("toml") +} + +fn edit_from_mock(m: &RequestMock) -> MockResponseEdit { + let mut headers = std::collections::BTreeMap::new(); + for (k, v) in &m.headers { + headers.insert(k.clone(), v.clone()); + } + let ct_is_json = headers + .iter() + .any(|(k, v)| k.eq_ignore_ascii_case("content-type") && v.contains("json")); + let (body_json, body_text) = match &m.body { + Some(b) => { + if let Some(j) = &b.json { + let s = serde_json::to_string_pretty(j).ok(); + (s, None) + } else if let Some(t) = &b.text { + // If content-type is JSON, surface this as JSON in the editor + // so the user sees a pretty-printed form. Falls back to text. + if ct_is_json { + match serde_json::from_str::(t) { + Ok(v) => (serde_json::to_string_pretty(&v).ok(), None), + Err(_) => (None, Some(t.clone())), + } + } else { + (None, Some(t.clone())) + } + } else { + (None, None) + } + } + None => (None, None), + }; + MockResponseEdit { + status: m.status, + headers, + auth: m.auth.clone(), + delay_ms: m.delay_ms, + body_json, + body_text, + } +} + +fn mock_from_edit(e: &MockResponseEdit) -> anyhow::Result { + let mut headers = IndexMap::new(); + for (k, v) in &e.headers { + headers.insert(k.clone(), v.clone()); + } + let body = match ( + e.body_json.as_ref().filter(|s| !s.trim().is_empty()), + e.body_text.as_ref().filter(|s| !s.is_empty()), + ) { + (Some(j), _) => { + // validate JSON before saving so the user gets immediate feedback + let value: serde_json::Value = + serde_json::from_str(j).map_err(|e| anyhow::anyhow!("invalid JSON: {e}"))?; + if !headers + .keys() + .any(|k| k.eq_ignore_ascii_case("content-type")) + { + headers.insert("content-type".into(), "application/json".into()); + } + // store as text to keep TOML clean and avoid toml::Value + // limitations (no null, no mixed-type arrays). The compact + // JSON form keeps the request file readable. + Some(MockBody { + text: Some(serde_json::to_string(&value)?), + json: None, + file: None, + }) + } + (None, Some(t)) => Some(MockBody { + json: None, + text: Some(t.clone()), + file: None, + }), + (None, None) => None, + }; + Ok(RequestMock { + status: e.status, + headers, + delay_ms: e.delay_ms, + auth: e.auth.clone(), + body, + }) +} + +#[tauri::command] +pub async fn mock_start( + app: tauri::AppHandle, + state: State<'_, MockState>, + workspace_root: String, + bind: Option, + port: Option, +) -> Result { + { + let g = state.inner.lock().unwrap(); + if g.is_some() { + return Err("mock server already running".into()); + } + } + + let root_pb = PathBuf::from(&workspace_root); + let ws = Workspace::discover(&root_pb).map_err(|e| e.to_string())?; + let spec = knock_mock_adapter::build(&ws).map_err(|e| e.to_string())?; + let route_count = spec.routes.len(); + + let cfg = ws.config.mock.as_ref(); + let bind_host = bind + .or_else(|| cfg.and_then(|c| c.bind.clone())) + .unwrap_or_else(|| "127.0.0.1".into()); + let bind_port = port.or_else(|| cfg.and_then(|c| c.port)).unwrap_or(3000); + let addr: SocketAddr = format!("{bind_host}:{bind_port}") + .parse() + .map_err(|e| format!("invalid bind {bind_host}:{bind_port}: {e}"))?; + + let emitter = app.clone(); + let sink: knock_mock::LogSink = Arc::new(move |e: LogEntry| { + let _ = emitter.emit(LOG_EVENT, e); + }); + + let handle = serve_with_logger(spec, addr, Some(sink)) + .await + .map_err(|e| e.to_string())?; + + let started_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let local = handle.addr; + + { + let mut g = state.inner.lock().unwrap(); + *g = Some(RunningMock { + workspace_root: ws.root.clone(), + addr: local, + started_at, + route_count, + handle: Some(handle), + }); + } + + let status = MockStatus { + running: true, + workspace_root: Some(ws.root.display().to_string()), + addr: Some(local.to_string()), + route_count: Some(route_count), + started_at: Some(started_at), + }; + let _ = app.emit(STATUS_EVENT, status.clone()); + Ok(status) +} + +#[tauri::command] +pub async fn mock_stop( + app: tauri::AppHandle, + state: State<'_, MockState>, +) -> Result { + let handle_opt = { + let mut g = state.inner.lock().unwrap(); + g.take().and_then(|mut r| r.handle.take()) + }; + if let Some(h) = handle_opt { + let _ = h.shutdown().await; + } + let status = MockStatus { + running: false, + workspace_root: None, + addr: None, + route_count: None, + started_at: None, + }; + let _ = app.emit(STATUS_EVENT, status.clone()); + Ok(status) +} diff --git a/apps/knock-app/src/App.tsx b/apps/knock-app/src/App.tsx index 23feeda..9cfff0c 100644 --- a/apps/knock-app/src/App.tsx +++ b/apps/knock-app/src/App.tsx @@ -28,6 +28,7 @@ import { WorkspaceAppearanceModal } from "./WorkspaceAppearanceModal"; import { Statusbar, SCALE_STEP, clampScale } from "./Statusbar"; import { OpenApiImportModal } from "./OpenApiImportModal"; import { OpenApiView } from "./OpenApiView"; +import { MockView } from "./MockView"; import { BottomTerminalDock } from "./BottomTerminalDock"; import { terminalStore, type KubeTerminalSpawnArgs } from "./kubeTerminalStore"; import type { DirEntryDto, RecentEntry } from "./types"; @@ -294,13 +295,15 @@ export function App() { async function refreshTree(root: string) { try { - const [list, dirList, colorMap, orderMap] = await Promise.all([ + const [list, envList, dirList, colorMap, orderMap] = await Promise.all([ invoke("list_tree", { root }), + invoke("list_envs", { root }), invoke("list_directories", { root }), invoke>("get_colors", { root }), invoke>("list_folder_orders", { root }), ]); setEntries(list); + setEnvs(envList); setDirectories(dirList.map((d) => d.rel)); setColors(colorMap); setFolderOrders(orderMap); @@ -1110,6 +1113,15 @@ export function App() { /> )} + {projectMode === "mock" && globalMode === "requests" && workspace && ( + <> +
Mock server
+
+ Start a local HTTP server built from this workspace's requests. + Live request logs appear on the right. +
+ + )} {globalMode === "kube" && ( <>
Kubeconfigs
@@ -1133,8 +1145,11 @@ export function App() { }} /> )} - {globalMode === "requests" && !selected &&
Select a request from the tree.
} - {globalMode === "requests" && selected && isRequest && form && ( + {globalMode === "requests" && projectMode === "mock" && workspace && ( + + )} + {globalMode === "requests" && projectMode !== "mock" && !selected &&
Select a request from the tree.
} + {globalMode === "requests" && projectMode !== "mock" && selected && isRequest && form && ( )} - {globalMode === "requests" && selected && isOpenApi && workspace && ( + {globalMode === "requests" && projectMode !== "mock" && selected && isOpenApi && workspace && ( setShowOpenApiImport(true)} /> )} - {globalMode === "requests" && selected && !isRequest && !isOpenApi && ( + {globalMode === "requests" && projectMode !== "mock" && selected && !isRequest && !isOpenApi && ( <>
{selected}{rawDirty && } diff --git a/apps/knock-app/src/MockView.tsx b/apps/knock-app/src/MockView.tsx new file mode 100644 index 0000000..ef55167 --- /dev/null +++ b/apps/knock-app/src/MockView.tsx @@ -0,0 +1,574 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +interface MockStatus { + running: boolean; + workspace_root: string | null; + addr: string | null; + route_count: number | null; + started_at: number | null; +} + +interface MockRouteSummary { + method: string; + path: string; + auth: string | null; + status: number; + request_rel: string; + source: "inline" | "sibling" | "openapi" | "stub" | "empty"; +} + +interface MockResponseEdit { + status: number | null; + headers: Record; + auth: string | null; + delay_ms: number | null; + body_json: string | null; + body_text: string | null; +} + +interface MockResponseRead { + origin: "inline" | "sibling" | "none"; + edit: MockResponseEdit; +} + +interface MockSpecPreview { + routes: MockRouteSummary[]; + auth_schemes: string[]; + default_delay_ms: number | null; +} + +interface LogEntry { + ts_ms: number; + method: string; + path: string; + status: number; + elapsed_ms: number; + remote: string | null; +} + +interface Props { + workspaceRoot: string; +} + +const MAX_LOGS = 500; + +export function MockView({ workspaceRoot }: Props) { + const [status, setStatus] = useState({ + running: false, + workspace_root: null, + addr: null, + route_count: null, + started_at: null, + }); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [port, setPort] = useState("3000"); + const [bind, setBind] = useState("127.0.0.1"); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + const [logs, setLogs] = useState([]); + const [paused, setPaused] = useState(false); + const [filter, setFilter] = useState(""); + const [selectedRel, setSelectedRel] = useState(null); + const [editorOrigin, setEditorOrigin] = + useState("none"); + const [editorStatus, setEditorStatus] = useState("200"); + const [editorBody, setEditorBody] = useState(""); + const [editorBodyKind, setEditorBodyKind] = useState<"json" | "text">("json"); + const [editorAuth, setEditorAuth] = useState(""); + const [editorHeaders, setEditorHeaders] = useState(""); + const [editorSaving, setEditorSaving] = useState(false); + const [editorError, setEditorError] = useState(null); + const logsRef = useRef(null); + const autoscrollRef = useRef(true); + + const refreshStatus = async () => { + try { + const s = await invoke("mock_status"); + setStatus(s); + } catch { + // ignore + } + }; + + const refreshPreview = async () => { + setPreviewError(null); + try { + const p = await invoke("mock_preview", { + workspaceRoot, + }); + setPreview(p); + } catch (e) { + setPreview(null); + setPreviewError(String(e)); + } + }; + + useEffect(() => { + refreshStatus(); + refreshPreview(); + }, [workspaceRoot]); + + useEffect(() => { + let cancelled = false; + const pending: Promise[] = [ + listen("mock://status", (e) => setStatus(e.payload)), + listen("mock://log", (e) => { + if (paused) return; + setLogs((cur) => { + const next = [...cur, e.payload]; + if (next.length > MAX_LOGS) next.splice(0, next.length - MAX_LOGS); + return next; + }); + }), + ]; + return () => { + cancelled = true; + pending.forEach((p) => + p.then((un) => { + if (cancelled) un(); + }), + ); + }; + }, [paused]); + + useEffect(() => { + if (!autoscrollRef.current) return; + const el = logsRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [logs]); + + const onScroll = () => { + const el = logsRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + autoscrollRef.current = atBottom; + }; + + const start = async () => { + setBusy(true); + setActionError(null); + try { + const portNum = port.trim() ? parseInt(port, 10) : null; + const s = await invoke("mock_start", { + workspaceRoot, + bind: bind.trim() || null, + port: portNum && !Number.isNaN(portNum) ? portNum : null, + }); + setStatus(s); + } catch (e) { + setActionError(String(e)); + } finally { + setBusy(false); + } + }; + + const stop = async () => { + setBusy(true); + setActionError(null); + try { + const s = await invoke("mock_stop"); + setStatus(s); + } catch (e) { + setActionError(String(e)); + } finally { + setBusy(false); + } + }; + + const openEditor = async (rel: string) => { + setSelectedRel(rel); + setEditorError(null); + try { + const r = await invoke("mock_read_response", { + workspaceRoot, + requestRel: rel, + }); + setEditorOrigin(r.origin); + setEditorStatus(r.edit.status ? String(r.edit.status) : "200"); + setEditorAuth(r.edit.auth ?? ""); + setEditorHeaders( + Object.entries(r.edit.headers ?? {}) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"), + ); + if (r.edit.body_json) { + setEditorBodyKind("json"); + setEditorBody(r.edit.body_json); + } else if (r.edit.body_text) { + setEditorBodyKind("text"); + setEditorBody(r.edit.body_text); + } else { + setEditorBodyKind("json"); + setEditorBody(""); + } + } catch (e) { + setEditorError(String(e)); + } + }; + + const closeEditor = () => { + setSelectedRel(null); + setEditorError(null); + }; + + const saveEditor = async () => { + if (!selectedRel) return; + setEditorSaving(true); + setEditorError(null); + try { + const headers: Record = {}; + editorHeaders + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .forEach((l) => { + const idx = l.indexOf(":"); + if (idx > 0) { + const k = l.slice(0, idx).trim(); + const v = l.slice(idx + 1).trim(); + if (k) headers[k] = v; + } + }); + const statusNum = parseInt(editorStatus, 10); + const edit: MockResponseEdit = { + status: Number.isNaN(statusNum) ? 200 : statusNum, + headers, + auth: editorAuth.trim() || null, + delay_ms: null, + body_json: editorBodyKind === "json" ? editorBody : null, + body_text: editorBodyKind === "text" ? editorBody : null, + }; + await invoke("mock_save_response", { + workspaceRoot, + requestRel: selectedRel, + edit, + }); + await refreshPreview(); + setEditorOrigin("sibling"); + // hot-reload: if the server is running for this workspace, restart + // so the new response takes effect on the next request. + if (sameWorkspace) { + try { + await invoke("mock_stop"); + const portNum = port.trim() ? parseInt(port, 10) : null; + const s = await invoke("mock_start", { + workspaceRoot, + bind: bind.trim() || null, + port: portNum && !Number.isNaN(portNum) ? portNum : null, + }); + setStatus(s); + } catch (e) { + setEditorError(`saved, but reload failed: ${e}`); + } + } + } catch (e) { + setEditorError(String(e)); + } finally { + setEditorSaving(false); + } + }; + + const clearEditor = async () => { + if (!selectedRel) return; + setEditorSaving(true); + setEditorError(null); + try { + await invoke("mock_clear_response", { + workspaceRoot, + requestRel: selectedRel, + }); + await refreshPreview(); + setEditorOrigin("none"); + setEditorStatus("200"); + setEditorBody(""); + setEditorAuth(""); + setEditorHeaders(""); + } catch (e) { + setEditorError(String(e)); + } finally { + setEditorSaving(false); + } + }; + + const filteredLogs = useMemo(() => { + const f = filter.trim().toLowerCase(); + if (!f) return logs; + return logs.filter( + (l) => + l.path.toLowerCase().includes(f) || + l.method.toLowerCase().includes(f) || + String(l.status).includes(f), + ); + }, [logs, filter]); + + const sameWorkspace = + status.running && status.workspace_root === workspaceRoot; + const runningElsewhere = + status.running && status.workspace_root !== workspaceRoot; + + return ( +
+
+ Mock server + + {status.running && status.addr && ( + + http://{status.addr} + + )} +
+ +
+ + + {!status.running && ( + + )} + {sameWorkspace && ( + + )} + {runningElsewhere && ( + + Running for another workspace ({status.workspace_root}). Stop it + first. + + )} + +
+ + {actionError &&
{actionError}
} + {previewError &&
spec: {previewError}
} + +
+
+
+ Routes {preview ? `(${preview.routes.length})` : ""} +
+ {!preview && !previewError &&
Loading…
} + {preview && preview.routes.length === 0 && ( +
+ No routes detected. Add requests under requests/ and{" "} + optional [mock] blocks or files under{" "} + mocks/. +
+ )} + {preview && ( + + + + + + + + + + + + {preview.routes.map((r, i) => ( + openEditor(r.request_rel)} + title={r.request_rel} + > + + + + + + + ))} + +
MethodPathStatusAuthSource
+ {r.method} + {r.path}{r.status}{r.auth ?? "—"} + + {r.source} + +
+ )} + {preview && preview.auth_schemes.length > 0 && ( +
+ Auth schemes: {preview.auth_schemes.join(", ")} +
+ )} +
+ + {selectedRel && ( +
+
+ Response · {selectedRel} + + {editorOrigin === "inline" + ? "from request file" + : editorOrigin === "sibling" + ? `mocks/${selectedRel}` + : "not defined"} + + +
+
+ + +