diff --git a/crates/fkst-framework/src/lua_coverage.rs b/crates/fkst-framework/src/lua_coverage.rs new file mode 100644 index 0000000..7531a11 --- /dev/null +++ b/crates/fkst-framework/src/lua_coverage.rs @@ -0,0 +1,365 @@ +use anyhow::{Context, Result}; +use mlua::{DebugEvent, HookTriggers, Lua, Thread, Value, VmState}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +const NO_SOURCE_ID: usize = usize::MAX; +const COVERAGE_TRIGGERS: HookTriggers = HookTriggers::new().on_calls().on_returns().every_line(); + +#[derive(Clone, Debug)] +pub(crate) struct LuaCoverage { + files: Arc>, + source_ids: Arc>, +} + +impl LuaCoverage { + pub(crate) fn new(roots: impl IntoIterator) -> Result { + let roots = roots.into_iter().collect::>(); + let files = collect_coverage_files(&roots)?; + let mut source_ids = BTreeMap::new(); + for (idx, file) in files.iter().enumerate() { + for source in &file.sources { + source_ids.insert(source.clone(), idx); + } + } + Ok(Self { + files: Arc::new(files), + source_ids: Arc::new(source_ids), + }) + } + + pub(crate) fn install(&self, lua: &Lua) -> mlua::Result<()> { + let coverage = self.clone(); + let state = Arc::new(HookState::default()); + lua.set_hook(COVERAGE_TRIGGERS, move |_, debug| { + coverage.record_hook_event(&state, debug); + Ok(VmState::Continue) + }); + self.install_coroutine_create_hook(lua) + } + + pub(crate) fn write_outputs(&self, dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let snapshot = self.snapshot(); + write_json(&dir.join("coverage.json"), &snapshot)?; + write_lcov(&dir.join("lcov.info"), &snapshot)?; + Ok(()) + } + + fn record_hook_event(&self, state: &HookState, debug: mlua::Debug<'_>) { + match debug.event() { + DebugEvent::Call => { + let id = self.source_id(debug.source().source.as_deref()); + state.enter(id); + } + DebugEvent::TailCall => { + let id = self.source_id(debug.source().source.as_deref()); + state.replace(id); + } + DebugEvent::Ret => state.leave(), + DebugEvent::Line => { + let line = debug.curr_line(); + if line > 0 { + self.mark(state.current(), line as u32); + } + } + DebugEvent::Count | DebugEvent::Unknown(_) => {} + } + } + + fn source_id(&self, source: Option<&str>) -> usize { + source + .and_then(|source| source.strip_prefix('@')) + .and_then(|source| self.source_ids.get(source).copied()) + .unwrap_or(NO_SOURCE_ID) + } + + fn install_coroutine_create_hook(&self, lua: &Lua) -> mlua::Result<()> { + let globals = lua.globals(); + let Value::Table(coroutine) = globals.get::("coroutine")? else { + return Ok(()); + }; + let create: mlua::Function = coroutine.get("create")?; + let coverage = self.clone(); + coroutine.set( + "create", + lua.create_function(move |_, func: mlua::Function| { + let thread = create.call::(func)?; + coverage.install_thread(&thread); + Ok(thread) + })?, + )?; + Ok(()) + } + + fn install_thread(&self, thread: &Thread) { + let coverage = self.clone(); + let state = Arc::new(HookState::default()); + thread.set_hook(COVERAGE_TRIGGERS, move |_, debug| { + coverage.record_hook_event(&state, debug); + Ok(VmState::Continue) + }); + } + + fn mark(&self, source_id: usize, line: u32) { + let Some(file) = self.files.get(source_id) else { + return; + }; + let line_idx = line.saturating_sub(1) as usize; + let word_idx = line_idx / 64; + let bit = 1u64 << (line_idx % 64); + if let Some(word) = file.words.get(word_idx) { + word.fetch_or(bit, Ordering::Relaxed); + } + } + + fn snapshot(&self) -> BTreeMap { + self.files + .iter() + .filter_map(|file| { + let covered_lines = file.covered_lines(); + if covered_lines.is_empty() { + None + } else { + Some((file.file.clone(), CoverageFile { covered_lines })) + } + }) + .collect() + } +} + +#[derive(Debug)] +struct CoverageHits { + file: String, + sources: Vec, + words: Vec, +} + +impl CoverageHits { + fn new(file: String, sources: Vec, line_count: usize) -> Self { + let word_count = line_count.div_ceil(64).max(1); + Self { + file, + sources, + words: (0..word_count).map(|_| AtomicU64::new(0)).collect(), + } + } + + fn covered_lines(&self) -> Vec { + let mut lines = Vec::new(); + for (word_idx, word) in self.words.iter().enumerate() { + let mut bits = word.load(Ordering::Relaxed); + while bits != 0 { + let bit_idx = bits.trailing_zeros() as usize; + lines.push((word_idx * 64 + bit_idx + 1) as u32); + bits &= !(1u64 << bit_idx); + } + } + lines + } +} + +#[derive(Debug)] +struct HookState { + current: AtomicUsize, + stack: Mutex>, +} + +impl Default for HookState { + fn default() -> Self { + Self { + current: AtomicUsize::new(NO_SOURCE_ID), + stack: Mutex::new(Vec::new()), + } + } +} + +impl HookState { + fn current(&self) -> usize { + self.current.load(Ordering::Relaxed) + } + + fn enter(&self, id: usize) { + let previous = self.current.swap(id, Ordering::Relaxed); + if let Ok(mut stack) = self.stack.lock() { + stack.push(previous); + } + } + + fn replace(&self, id: usize) { + self.current.store(id, Ordering::Relaxed); + } + + fn leave(&self) { + let previous = self + .stack + .lock() + .ok() + .and_then(|mut stack| stack.pop()) + .unwrap_or(NO_SOURCE_ID); + self.current.store(previous, Ordering::Relaxed); + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct CoverageFile { + covered_lines: Vec, +} + +pub(crate) fn chunk_name(path: &Path, owner_root: &Path) -> String { + let rel = path.strip_prefix(owner_root).unwrap_or(path); + format!("@{}", normalize_path(rel)) +} + +fn normalize_source(source: Option<&str>, roots: &[PathBuf]) -> Option { + let source = source?; + let file = source.strip_prefix('@')?; + if file.is_empty() || file.starts_with("fkst:") || file.ends_with("_test.lua") { + return None; + } + let path = Path::new(file); + let path = roots + .iter() + .find_map(|root| path.strip_prefix(root).ok()) + .unwrap_or(path); + let normalized = normalize_path(path); + if normalized.is_empty() || normalized.ends_with("_test.lua") { + return None; + } + Some(normalized) +} + +fn collect_coverage_files(roots: &[PathBuf]) -> Result> { + let mut files = BTreeMap::::new(); + for root in roots { + collect_coverage_files_from_root(root, root, &mut files) + .with_context(|| format!("scan coverage files in {}", root.display()))?; + } + Ok(files + .into_iter() + .map(|(file, source)| CoverageHits::new(file, source.sources, source.line_count)) + .collect()) +} + +fn collect_coverage_files_from_root( + root: &Path, + dir: &Path, + files: &mut BTreeMap, +) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_coverage_files_from_root(root, &path, files)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("lua") { + let file = chunk_name(&path, root); + let Some(file) = normalize_source(Some(&file), &[]) else { + continue; + }; + let source = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + files.entry(file.clone()).or_insert_with(|| CoverageSource { + sources: coverage_source_names(&path, &file), + line_count: line_count(&source), + }); + } + } + Ok(()) +} + +#[derive(Debug)] +struct CoverageSource { + sources: Vec, + line_count: usize, +} + +fn coverage_source_names(path: &Path, normalized_file: &str) -> Vec { + let mut sources = vec![normalized_file.to_string()]; + let raw_path = path.to_string_lossy().into_owned(); + if raw_path != normalized_file { + sources.push(raw_path); + } + sources +} + +fn line_count(source: &str) -> usize { + source + .as_bytes() + .iter() + .filter(|byte| **byte == b'\n') + .count() + + 1 +} + +fn normalize_path(path: &Path) -> String { + path.components() + .filter_map(|component| match component { + Component::Normal(part) => Some(part.to_string_lossy().into_owned()), + Component::CurDir => None, + Component::ParentDir => Some("..".to_string()), + Component::RootDir | Component::Prefix(_) => None, + }) + .collect::>() + .join("/") +} + +fn write_json(path: &Path, snapshot: &BTreeMap) -> Result<()> { + let data = serde_json::to_vec_pretty(snapshot)?; + write_atomic(path, &data) +} + +fn write_lcov(path: &Path, snapshot: &BTreeMap) -> Result<()> { + let mut data = String::new(); + for (file, entry) in snapshot { + data.push_str("TN:\n"); + data.push_str("SF:"); + data.push_str(file); + data.push('\n'); + for line in &entry.covered_lines { + data.push_str("DA:"); + data.push_str(&line.to_string()); + data.push_str(",1\n"); + } + data.push_str("end_of_record\n"); + } + write_atomic(path, data.as_bytes()) +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + let file_name = path + .file_name() + .ok_or_else(|| anyhow::anyhow!("coverage path has no file name"))?; + let tmp_path = parent.join(format!( + ".{}.{}.tmp", + file_name.to_string_lossy(), + std::process::id() + )); + std::fs::write(&tmp_path, data) + .with_context(|| format!("write temporary coverage {}", tmp_path.display()))?; + std::fs::rename(&tmp_path, path) + .with_context(|| format!("rename {} to {}", tmp_path.display(), path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunk_name_uses_at_prefixed_relative_paths() { + let root = Path::new("/tmp/pkg"); + let path = root.join("departments/worker/main.lua"); + assert_eq!(chunk_name(&path, root), "@departments/worker/main.lua"); + } +} diff --git a/crates/fkst-framework/src/main.rs b/crates/fkst-framework/src/main.rs index 5d0142c..5ecbe43 100644 --- a/crates/fkst-framework/src/main.rs +++ b/crates/fkst-framework/src/main.rs @@ -25,6 +25,7 @@ mod config_registry; mod external_command; mod host_conformance; mod init_package_repo; +mod lua_coverage; mod mlua_init; mod path_resolver; mod process_tree; @@ -82,7 +83,7 @@ enum CliCommand { Test(TestCli), InitPackageRepo(init_package_repo::InitPackageRepoOptions), CodexWorker(sdk_codex::CodexWorkerOptions), - SelfTest, + SelfTest(SelfTestCli), } fn parse_args() -> Result { @@ -94,10 +95,8 @@ fn parse_args() -> Result { ) })?; if sub == "--self-test" { - if let Some(other) = args_iter.next() { - anyhow::bail!("unknown --self-test option: {}", other); - } - return Ok(CliCommand::SelfTest); + let rest = args_iter.collect::>(); + return Ok(CliCommand::SelfTest(parse_self_test_args(&rest)?)); } if sub == "__codex-worker" { return Ok(CliCommand::CodexWorker(sdk_codex::parse_worker_args( @@ -235,6 +234,12 @@ struct ConfigCli { struct TestCli { roots: PackageRoots, report_json: Option, + coverage: Option, +} + +#[derive(Clone, Debug)] +struct SelfTestCli { + coverage: Option, } fn parse_conformance_args(args: &[String]) -> Result { @@ -297,6 +302,7 @@ fn parse_test_args(args: &[String]) -> Result { let mut project_root: Option = None; let mut package_roots: Vec = Vec::new(); let mut report_json: Option = None; + let mut coverage: Option = None; let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -318,6 +324,19 @@ fn parse_test_args(args: &[String]) -> Result { i += 1; report_json = Some(next_value(args, i, "--report-json")?.into()); } + "--coverage" => { + if coverage.is_some() { + anyhow::bail!("duplicate --coverage"); + } + i += 1; + coverage = Some(next_value(args, i, "--coverage")?.into()); + } + arg if arg.starts_with("--coverage=") => { + if coverage.is_some() { + anyhow::bail!("duplicate --coverage"); + } + coverage = Some(arg["--coverage=".len()..].into()); + } other => anyhow::bail!("unknown test argument: {}", other), } i += 1; @@ -327,9 +346,35 @@ fn parse_test_args(args: &[String]) -> Result { Ok(TestCli { roots: PackageRoots::resolve(root, package_roots)?, report_json, + coverage, }) } +fn parse_self_test_args(args: &[String]) -> Result { + let mut coverage: Option = None; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--coverage" => { + if coverage.is_some() { + anyhow::bail!("duplicate --coverage"); + } + i += 1; + coverage = Some(next_value(args, i, "--coverage")?.into()); + } + arg if arg.starts_with("--coverage=") => { + if coverage.is_some() { + anyhow::bail!("duplicate --coverage"); + } + coverage = Some(arg["--coverage=".len()..].into()); + } + other => anyhow::bail!("unknown --self-test option: {}", other), + } + i += 1; + } + Ok(SelfTestCli { coverage }) +} + fn parse_init_package_repo_args( args: &[String], ) -> Result { @@ -589,11 +634,21 @@ fn run() -> Result { program, args, } => run_rate_exec(&pool, program, args), - CliCommand::Test(options) => test_runner::run_tests(options.roots, options.report_json), + CliCommand::Test(options) => { + test_runner::run_tests(options.roots, options.report_json, options.coverage) + } CliCommand::InitPackageRepo(options) => init_package_repo::run(options), CliCommand::CodexWorker(options) => sdk_codex::run_codex_worker(options), - CliCommand::SelfTest => match self_test::run() { - Ok(()) => Ok(0), + CliCommand::SelfTest(options) => match self_test::run() { + Ok(()) => { + if let Some(coverage) = options.coverage { + let cwd = std::env::current_dir().context("read current directory")?; + let roots = PackageRoots::resolve(&cwd, vec![cwd.clone()])?; + test_runner::run_tests(roots, None, Some(coverage)) + } else { + Ok(0) + } + } Err(err) => { eprintln!("SELF_TEST_FAILED:{}: {:#}", err.class(), err.source()); Ok(2) diff --git a/crates/fkst-framework/src/mlua_init.rs b/crates/fkst-framework/src/mlua_init.rs index e5fe85e..6d4d258 100644 --- a/crates/fkst-framework/src/mlua_init.rs +++ b/crates/fkst-framework/src/mlua_init.rs @@ -112,23 +112,28 @@ pub(crate) struct LuaChunkCache { } impl LuaChunkCache { - pub(crate) fn load_cached_chunk(&self, lua: &Lua, path: &Path) -> Result<()> { - let bytecode = self.bytecode_for(path)?; + pub(crate) fn load_cached_chunk_with_name( + &self, + lua: &Lua, + path: &Path, + owner_root: &Path, + ) -> Result<()> { + let bytecode = self.bytecode_for(path, owner_root)?; lua.load(bytecode.as_slice()) - .set_name(path.to_string_lossy()) + .set_name(crate::lua_coverage::chunk_name(path, owner_root)) .exec() .with_context(|| format!("exec {}", path.display())) } pub(crate) fn eval_cached_chunk(&self, lua: &Lua, path: &Path) -> Result { - let bytecode = self.bytecode_for(path)?; + let bytecode = self.bytecode_for(path, path)?; lua.load(bytecode.as_slice()) .set_name(path.to_string_lossy()) .eval() .with_context(|| format!("eval {}", path.display())) } - fn bytecode_for(&self, path: &Path) -> Result> { + fn bytecode_for(&self, path: &Path, owner_root: &Path) -> Result> { let src = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; let key = CachedChunkKey::for_path(path, src.as_bytes())?; @@ -144,7 +149,7 @@ impl LuaChunkCache { let lua = new_lua(); let function = lua .load(&src) - .set_name(path.to_string_lossy()) + .set_name(crate::lua_coverage::chunk_name(path, owner_root)) .into_function() .with_context(|| format!("compile {}", path.display()))?; let bytecode = function.dump(true); @@ -216,6 +221,10 @@ pub fn run_dept_with_require_roots<'a>( package_roots: impl IntoIterator, ) -> Result<()> { let package_roots = package_roots.into_iter().collect::>(); + let owner_root = package_roots + .first() + .copied() + .ok_or_else(|| anyhow::anyhow!("department runner requires at least one package root"))?; let roots_label = package_roots .iter() .map(|root| root.display().to_string()) @@ -223,7 +232,7 @@ pub fn run_dept_with_require_roots<'a>( .join(";"); set_package_roots_path(lua, package_roots.iter().copied()) .with_context(|| format!("set package.path for {}", roots_label))?; - run_dept_with_package_path_and_chunk_cache(lua, lua_path, event, None) + run_dept_with_package_path_chunk_cache_and_name_root(lua, lua_path, event, None, owner_root) } pub(crate) fn run_dept_with_package_path_and_chunk_cache( @@ -231,13 +240,25 @@ pub(crate) fn run_dept_with_package_path_and_chunk_cache( lua_path: &Path, event: &JsonValue, cache: Option<&LuaChunkCache>, +) -> Result<()> { + run_dept_with_package_path_chunk_cache_and_name_root(lua, lua_path, event, cache, lua_path) +} + +pub(crate) fn run_dept_with_package_path_chunk_cache_and_name_root( + lua: &Lua, + lua_path: &Path, + event: &JsonValue, + cache: Option<&LuaChunkCache>, + owner_root: &Path, ) -> Result<()> { if let Some(cache) = cache { - cache.load_cached_chunk(lua, lua_path)?; + cache.load_cached_chunk_with_name(lua, lua_path, owner_root)?; } else { let src = std::fs::read_to_string(lua_path) .with_context(|| format!("read {}", lua_path.display()))?; - let chunk = lua.load(&src).set_name(lua_path.to_string_lossy()); + let chunk = lua + .load(&src) + .set_name(crate::lua_coverage::chunk_name(lua_path, owner_root)); chunk .exec() .with_context(|| format!("exec {}", lua_path.display()))?; @@ -332,13 +353,17 @@ mod tests { let cache = LuaChunkCache::default(); let lua = new_lua(); - cache.load_cached_chunk(&lua, &main).unwrap(); + cache + .load_cached_chunk_with_name(&lua, &main, dir.path()) + .unwrap(); let value: String = lua.globals().get("value").unwrap(); assert_eq!(value, "first"); std::fs::write(&main, second).unwrap(); force_mtime(&main, 1_000); - cache.load_cached_chunk(&lua, &main).unwrap(); + cache + .load_cached_chunk_with_name(&lua, &main, dir.path()) + .unwrap(); let value: String = lua.globals().get("value").unwrap(); assert_eq!(value, "fresh"); @@ -373,6 +398,49 @@ mod tests { assert_eq!(called, "ok"); } + #[test] + fn run_dept_names_loaded_chunk_relative_to_package_root() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("departments/demo")).unwrap(); + let main = dir.path().join("departments/demo/main.lua"); + std::fs::write( + &main, + r#" + function pipeline(event) + called = true + end + "#, + ) + .unwrap(); + + let lua = new_lua(); + let sources = Arc::new(Mutex::new(Vec::::new())); + let hook_sources = sources.clone(); + lua.set_hook(mlua::HookTriggers::new().on_calls(), move |_, debug| { + if let Some(source) = debug.source().source { + if let Ok(mut sources) = hook_sources.lock() { + sources.push(source.into_owned()); + } + } + Ok(mlua::VmState::Continue) + }); + run_dept_with_require_roots(&lua, &main, &serde_json::json!({}), [dir.path()]).unwrap(); + let called: bool = lua.globals().get("called").unwrap(); + let sources = sources.lock().unwrap(); + + assert!(called); + assert!( + sources + .iter() + .any(|source| source == "@departments/demo/main.lua"), + "sources: {sources:?}" + ); + assert!( + sources.iter().all(|source| source != "@"), + "sources: {sources:?}" + ); + } + #[test] fn set_package_root_path_replaces_existing_search_path() { let dir = TempDir::new().unwrap(); diff --git a/crates/fkst-framework/src/test_runner.rs b/crates/fkst-framework/src/test_runner.rs index 2dc2894..643bb86 100644 --- a/crates/fkst-framework/src/test_runner.rs +++ b/crates/fkst-framework/src/test_runner.rs @@ -10,13 +10,31 @@ use crate::external_command::{ CommandCassetteMode, CommandCassetteOptions, CommandCassetteRedaction, MockCommandResult, MockCommandState, }; +use crate::lua_coverage::LuaCoverage; use crate::path_resolver::PackageRoots; use crate::raise::RaiseBuffer; -pub(crate) fn run_tests(roots: PackageRoots, report_json: Option) -> Result { +pub(crate) fn run_tests( + roots: PackageRoots, + report_json: Option, + coverage_dir: Option, +) -> Result { let files = discover_test_files(&roots)?; let _supervisor_pid_guard = TestModeSupervisorPidGuard::remove(); let cache = TestRunCache::new(roots.clone()); + let coverage = coverage_dir + .as_ref() + .map(|_| { + LuaCoverage::new( + roots + .graph_roots() + .into_iter() + .map(|root| root.root) + .collect::>(), + ) + }) + .transpose() + .context("initialize Lua coverage")?; let mut passed = 0usize; let mut failed = 0usize; let mut report = TestReport::new(); @@ -47,10 +65,16 @@ pub(crate) fn run_tests(roots: PackageRoots, report_json: Option) -> Re file.owner_root.clone(), file.owner_namespace.clone(), mock_commands.clone(), + coverage.clone(), ) .with_context(|| format!("register fkst.test for {}", relpath))?; + if let Some(coverage) = &coverage { + coverage + .install(&lua) + .with_context(|| format!("install coverage hook for {}", relpath))?; + } - match load_test_table(&lua, &file.path) { + match load_test_table(&lua, &file.path, &file.owner_root) { Ok(tests) => { for (name, func) in tests { mock_commands @@ -94,6 +118,11 @@ pub(crate) fn run_tests(roots: PackageRoots, report_json: Option) -> Re write_report_json(&path, &report) .with_context(|| format!("write test report {}", path.display()))?; } + if let (Some(path), Some(coverage)) = (coverage_dir, coverage) { + coverage + .write_outputs(&path) + .with_context(|| format!("write coverage outputs {}", path.display()))?; + } Ok(if failed == 0 { 0 } else { 1 }) } @@ -270,9 +299,11 @@ fn is_test_file(path: &Path) -> bool { .is_some_and(|name| name.ends_with("_test.lua")) } -fn load_test_table(lua: &Lua, file: &Path) -> Result> { +fn load_test_table(lua: &Lua, file: &Path, owner_root: &Path) -> Result> { let src = std::fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?; - let chunk = lua.load(&src).set_name(file.to_string_lossy()); + let chunk = lua + .load(&src) + .set_name(crate::lua_coverage::chunk_name(file, owner_root)); let table: Table = chunk .eval() .with_context(|| format!("eval {}", file.display()))?; @@ -297,6 +328,7 @@ fn register_test_sdk( owner_root: PathBuf, owner_namespace: String, mock_commands: MockCommandState, + coverage: Option, ) -> mlua::Result<()> { let globals = lua.globals(); let fkst = match globals.get::("fkst")? { @@ -450,6 +482,7 @@ fn register_test_sdk( path, event, opts, + coverage.clone(), ) }, )? @@ -514,6 +547,7 @@ fn run_department( path: String, event: Value, opts: Option, + coverage: Option, ) -> mlua::Result
{ let opts = DeptRunOptions::from_lua(opts)?; let lua_path = resolve_department_path(owner_root, &path); @@ -550,12 +584,21 @@ fn run_department( Some(roots.clone()), graph_json_authorized, )?; + if let Some(coverage) = &coverage { + coverage.install(&dept_lua)?; + } - let exit_code = match crate::mlua_init::run_dept_with_package_path_and_chunk_cache( + let chunk_cache = if coverage.is_some() { + None + } else { + Some(cache.lua_chunk_cache()) + }; + let exit_code = match crate::mlua_init::run_dept_with_package_path_chunk_cache_and_name_root( &dept_lua, &lua_path, &event_json, - Some(cache.lua_chunk_cache()), + chunk_cache, + owner_root, ) { Ok(()) => 0, Err(err) => { @@ -1084,6 +1127,7 @@ mod tests { "departments/worker/main.lua".to_string(), first_event, None, + None, ) .unwrap(); assert_eq!(first.get::("exit_code").unwrap(), 0); @@ -1118,6 +1162,7 @@ mod tests { "departments/worker/main.lua".to_string(), second_event, None, + None, ) .unwrap(); diff --git a/crates/fkst-framework/tests/self_test_cli.rs b/crates/fkst-framework/tests/self_test_cli.rs index 0bc7514..781f979 100644 --- a/crates/fkst-framework/tests/self_test_cli.rs +++ b/crates/fkst-framework/tests/self_test_cli.rs @@ -131,6 +131,75 @@ fn self_test_reports_permit_pool_slot_env_failure() { assert!(stderr.contains(CODEX_PERMIT_SLOTS_ENV), "{stderr}"); } +#[test] +fn self_test_coverage_runs_lua_tests_and_writes_artifacts() { + let tmp = tempfile::Builder::new().prefix("repo").tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("departments/probe")).unwrap(); + std::fs::create_dir_all(tmp.path().join("tests")).unwrap(); + std::fs::write( + tmp.path().join("departments/probe/main.lua"), + r#" +function pipeline(event) + local value = event.payload.value .. "-covered" + raise("done", { value = value }) +end +"#, + ) + .unwrap(); + std::fs::write( + tmp.path().join("tests/self_test_coverage_test.lua"), + r#" +local t = fkst.test + +return { + test_department = function() + local result = fkst.test.run_department("departments/probe/main.lua", { payload = { value = "ok" } }) + t.eq(result.exit_code, 0) + t.eq(result.raises[1].payload.value, "ok-covered") + end, +} +"#, + ) + .unwrap(); + let coverage_dir = tmp.path().join("coverage"); + + let output = Command::new(framework_bin()) + .arg("--self-test") + .arg("--coverage") + .arg(&coverage_dir) + .env(RUNTIME_ROOT_ENV, ".fkst/runtime") + .current_dir(tmp.path()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let coverage: serde_json::Value = + serde_json::from_slice(&std::fs::read(coverage_dir.join("coverage.json")).unwrap()) + .unwrap(); + assert!( + coverage.get("departments/probe/main.lua").is_some(), + "coverage: {coverage}" + ); + assert!( + coverage.get("tests/self_test_coverage_test.lua").is_none(), + "coverage: {coverage}" + ); + let lcov = std::fs::read_to_string(coverage_dir.join("lcov.info")).unwrap(); + assert!( + lcov.contains("SF:departments/probe/main.lua"), + "lcov: {lcov}" + ); + assert!( + !lcov.contains("self_test_coverage_test.lua"), + "lcov: {lcov}" + ); +} + #[test] fn run_subcommand_still_executes_pipeline() { let tmp = tempfile::Builder::new().prefix("repo").tempdir().unwrap(); diff --git a/crates/fkst-framework/tests/test_runner_cli.rs b/crates/fkst-framework/tests/test_runner_cli.rs index a156810..267f5d5 100644 --- a/crates/fkst-framework/tests/test_runner_cli.rs +++ b/crates/fkst-framework/tests/test_runner_cli.rs @@ -98,6 +98,21 @@ fn run_lua_tests_with_packages_and_report( .unwrap() } +fn run_lua_tests_with_coverage(host: &Path, package: &Path, coverage: &Path) -> Output { + framework_command() + .arg("test") + .arg("--project-root") + .arg(host) + .arg("--package-root") + .arg(package) + .arg("--coverage") + .arg(coverage) + .current_dir(host) + .env("FKST_RUNTIME_ROOT", host.join(".fkst/runtime")) + .output() + .unwrap() +} + fn read_report(path: &Path) -> serde_json::Value { serde_json::from_slice(&fs::read(path).unwrap()).unwrap() } @@ -1012,7 +1027,9 @@ return M .unwrap(); fs::create_dir_all(package.path().join("tests")).unwrap(); fs::write( - package.path().join("tests/dead_letter_event_queue_test.lua"), + package + .path() + .join("tests/dead_letter_event_queue_test.lua"), format!( r#" local t = fkst.test @@ -1680,6 +1697,99 @@ return { ))); } +#[test] +fn test_coverage_writes_json_and_lcov_for_production_lua_lines() { + let host = tempfile::Builder::new().prefix("repo").tempdir().unwrap(); + fs::create_dir_all(host.path().join("departments/probe")).unwrap(); + fs::create_dir_all(host.path().join("tests")).unwrap(); + fs::write( + host.path().join("departments/probe/main.lua"), + r#" +local helper = require("helper") + +function pipeline(event) + local value = helper.value(event.payload.value) + local co = coroutine.create(function() + helper.from_coroutine() + end) + local ok, err = coroutine.resume(co) + assert(ok, err) + raise("done", { value = value }) +end +"#, + ) + .unwrap(); + fs::write( + host.path().join("helper.lua"), + r#" +local M = {} + +function M.value(value) + local result = value .. "-covered" + return result +end + +function M.from_coroutine() + local marker = "coroutine-covered" + return marker +end + +return M +"#, + ) + .unwrap(); + fs::write( + host.path().join("tests/coverage_test.lua"), + r#" +local t = fkst.test + +return { + test_department = function() + local result = fkst.test.run_department("departments/probe/main.lua", { payload = { value = "ok" } }) + t.eq(result.exit_code, 0) + t.eq(result.raises[1].queue, "done") + t.eq(result.raises[1].payload.value, "ok-covered") + end, +} +"#, + ) + .unwrap(); + let coverage_dir = host.path().join("coverage"); + + let output = run_lua_tests_with_coverage(host.path(), host.path(), &coverage_dir); + + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + stdout(&output), + stderr(&output) + ); + let coverage = read_report(&coverage_dir.join("coverage.json")); + assert!( + coverage.get("departments/probe/main.lua").is_some(), + "coverage: {coverage}" + ); + assert!(coverage.get("helper.lua").is_some(), "coverage: {coverage}"); + assert!( + coverage.get("tests/coverage_test.lua").is_none(), + "test files must be excluded from production coverage: {coverage}" + ); + let helper_lines = coverage["helper.lua"]["covered_lines"].as_array().unwrap(); + assert!( + helper_lines.iter().any(|line| line.as_u64() == Some(10)), + "coroutine-created lines must be covered: {coverage}" + ); + let lcov = fs::read_to_string(coverage_dir.join("lcov.info")).unwrap(); + assert!( + lcov.contains("SF:departments/probe/main.lua"), + "lcov: {lcov}" + ); + assert!(lcov.contains("SF:helper.lua"), "lcov: {lcov}"); + assert!(lcov.contains("DA:10,1"), "lcov: {lcov}"); + assert!(!lcov.contains("coverage_test.lua"), "lcov: {lcov}"); + assert!(!lcov.contains("BRDA:"), "lcov: {lcov}"); +} + #[test] fn test_runner_mocks_external_commands_fail_closed_and_isolates_tests() { let host = tempfile::Builder::new().prefix("repo").tempdir().unwrap(); diff --git a/docs/architecture.md b/docs/architecture.md index 04f79ae..ce4102b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,8 +80,8 @@ fkst-framework supervise --project-root [--package-root ...] --fra fkst-framework conformance --project-root [--package-root ...] fkst-framework config --project-root [--package-root ...] fkst-framework boundary-resources -fkst-framework test --project-root [--package-root ...] [--report-json ] -fkst-framework --self-test +fkst-framework test --project-root [--package-root ...] [--report-json ] [--coverage ] +fkst-framework --self-test [--coverage ] ``` `fkst-supervisor` 没有业务子命令;它只把当前目录作为 host root,启动 `fkst-framework supervise`。 @@ -114,6 +114,10 @@ runner 不全树递归,不扫描 `raisers/` 或 `fkst/`。每个测试文件 失败条目额外包含 `error`。加载或 eval 测试文件失败时,报告条目使用 `name = ""` 且计入 failed。报告条目来自 Rust 侧枚举出的测试文件和 `test_*` key;每个条目的身份是 `owner_namespace`、`file`、`name` 三元组,不提供可被分隔符碰撞污染的拼接 `id`。Lua `print` 不能向报告注入伪造测试;stdout 的 `PASS` / `FAIL` / summary 行保留为 legacy human / compatibility surface,不是 authoritative machine channel。 +`--coverage ` is an opt-in engine-owned Lua line coverage mode for the test runner. It installs an `mlua` `HookTriggers::EVERY_LINE` hook only for the covered run, names engine-loaded file chunks as `@`, and writes `/coverage.json` plus `/lcov.info` after the full run. `coverage.json` has the shape `{ "": { "covered_lines": [1, 2] } }`; `lcov.info` emits only `TN`, `SF`, `DA`, and `end_of_record` records. This is honest line coverage only: branch, condition, and mutation evidence are outside this surface. Files ending in `*_test.lua` and generated chunks named `=fkst:` are excluded from production coverage. Hooks are also applied to threads created through the standard `coroutine.create` table during coverage runs. Without `--coverage`, no hook is installed and the runner keeps the normal zero-coverage-overhead path. + +`fkst-framework --self-test --coverage ` first runs the normal engine self-test, then runs the same Lua test runner against the current directory as the folded host/package root and writes the same coverage artifacts. Plain `--self-test` remains the startup self-test and does not run package Lua tests. + 明确非目标:当前 test runner 不向 package author 提供 router、可靠投递或 supervise 的 Lua 原语;不承担 Lua stray-global 或 unused-local lint;不使用随机 sentinel 或专用 fd 分离测试输出;不沙箱恶意 Lua。它的范围是运行受信 package 的 Lua 测试、提供 Rust 枚举来源的机器报告,并保持 stdout 兼容面。 `fkst.test` 包含 `eq(actual, expected[, msg])`、`is_true(value[, msg])`、`raises(fn[, msg])`、`is_nil(value[, msg])` 四个断言,以及 test-mode-only `run_department(path, event[, opts])`。`run_department` 用 fresh Lua state 注册 production SDK 和独立 `RaiseBuffer`,再通过正常 department runner 注入 `event`;它返回 `{ exit_code = int, raises = { { queue = string, payload = table }, ... } }`。queue 解析与 production 一致,唯一例外是 `run_department` 会记录但不投递 subject department 在 `M.spec.produces` 中声明的 qualified queue raise。每个测试文件按所属 graph root 隔离执行;相对 `path` 按该测试文件所属的 owner package root 解析,运行期 `package.path` 也只指向该 owner root。绝对 `path` 仍按绝对路径处理。`opts.cwd`、`opts.env`、`opts.path_prepend` 只作用于该次执行并随后恢复。 diff --git a/docs/package-repo-contract.md b/docs/package-repo-contract.md index 03da91f..44c61ed 100644 --- a/docs/package-repo-contract.md +++ b/docs/package-repo-contract.md @@ -219,9 +219,9 @@ Pool ledgers live under `FKST_RATE_POOL_ROOT`, default `~/.fkst/rate-pools`. Thi 当前 `fkst-framework` CLI surface 来自 `crates/fkst-framework/src/main.rs`: ```text -fkst-framework --self-test +fkst-framework --self-test [--coverage ] fkst-framework conformance --project-root [--package-root ...] -fkst-framework test --project-root [--package-root ...] [--report-json ] +fkst-framework test --project-root [--package-root ...] [--report-json ] [--coverage ] fkst-framework run --project-root --package-root [--package-root ...] [--owner-namespace ] --event fkst-framework supervise --project-root --framework-bin [--package-root ...] fkst-framework config --project-root [--package-root ...] @@ -230,6 +230,8 @@ fkst-framework init-package-repo [--ref ] [--force] `--self-test` 运行引擎自检。`conformance` 支持 flat single-root 与 composed multi-root,通过 `--project-root` 和可重复 `--package-root` 形成 host + package graph。`test` 发现 `/departments/*/*_test.lua` 与 `/tests/*_test.lua`;`--report-json ` 写 schema 为 `fkst.test.report.v1` 的机器报告,条目身份是 `owner_namespace`、`file`、`name`。stdout 的 `PASS` / `FAIL` / summary 行只是 human / compatibility surface,不是 authoritative inventory。 +`--coverage ` is opt-in engine-owned Lua line coverage. It installs an `mlua` line hook only for that test run, writes `/coverage.json` as `{ "": { "covered_lines": [n] } }`, and writes `/lcov.info` with line-only `DA` records. It excludes `*_test.lua` and generated `=fkst:` chunks, and it names engine-loaded chunks as `@` so `debug.source()` maps back to package source files. The surface is line-granularity only; branch/condition coverage and mutation evidence remain outside the engine coverage primitive. Hooks are applied to the main Lua state and threads created through standard `coroutine.create` during coverage runs. Without `--coverage`, no coverage hook is installed. `fkst-framework --self-test --coverage ` runs the normal self-test and then the same Lua test runner against the current directory as a folded host/package root before writing these artifacts. + `run` 执行一个 Lua entrypoint。无 `--owner-namespace` 时,只在单一 package root 可唯一确定 owner namespace 的情况下默认;多个 `--package-root` 时必须传 `--owner-namespace `。当前 `run` 明确拒绝 `FKST_PACKAGE_ROOTS` env;应通过可重复 `--package-root` 传 composed namespace catalog。 `supervise` 扫描 package roots 与 host root,构造一张 composed graph,spawn consumer/source runtime,并用 `--framework-bin` 指定 child `fkst-framework run` binary。`config` 是只读自省命令,不是 package 行为入口,但它属于当前 CLI surface。