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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ jobs:
with:
cache-key: ci-packaging-smoke

- name: Build release binaries (for packaging smoke)
run: cargo build --release -p tnmsc -p tnmsm

- name: CLI packaging smoke
run: cargo test -p tnmsc-integrate-tests packaging_smoke_covers_release_binary_and_global_install -- --exact --nocapture

Expand Down
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ members = [
]

[workspace.package]
version = "2026.10424.111"
version = "2026.10425.10151"
edition = "2024"
rust-version = "1.88"
license = "AGPL-3.0-only"
Expand Down
69 changes: 66 additions & 3 deletions cli/local-tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;

static BINARY_BUILT: OnceLock<()> = OnceLock::new();
static PROJECT_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
Expand Down Expand Up @@ -38,18 +39,21 @@ pub struct LocalTestRunner {
binary: PathBuf,
cwd: PathBuf,
_lock_guard: std::sync::MutexGuard<'static, ()>,
_file_lock: CrossProcessLock,
}

impl LocalTestRunner {
/// 默认在 ~/workspace/memory-sync/ 下运行测试。
/// 若该目录不存在,则回退到当前目录。
pub fn new() -> Self {
ensure_binary();
// 所有测试共享同一个真实项目目录,必须串行执行
// Cross-process lock: serialises test binaries sharing the same project
let file_lock = acquire_cross_process_lock();
// In-process lock: serialises tests within a single binary
let guard = PROJECT_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("project lock should not be poisoned");
.unwrap_or_else(|e| e.into_inner());
let default_project = home_dir().join("workspace").join("memory-sync");
let cwd = if default_project.is_dir() {
default_project
Expand All @@ -60,15 +64,17 @@ impl LocalTestRunner {
binary: binary_path(),
cwd,
_lock_guard: guard,
_file_lock: file_lock,
}
}

pub fn with_cwd(cwd: impl AsRef<Path>) -> Self {
ensure_binary();
let file_lock = acquire_cross_process_lock();
let guard = PROJECT_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("project lock should not be poisoned");
.unwrap_or_else(|e| e.into_inner());
let cwd = cwd.as_ref().to_path_buf();
assert!(
cwd.is_dir(),
Expand All @@ -79,6 +85,7 @@ impl LocalTestRunner {
binary: binary_path(),
cwd,
_lock_guard: guard,
_file_lock: file_lock,
}
}

Expand Down Expand Up @@ -126,6 +133,21 @@ impl LocalTestRunner {
command_output(&mut cmd, &format!("tnmsc {}", args.join(" ")))
}

/// 在指定目录下运行 tnmsc 命令,并设置额外环境变量。
pub fn run_at_with_env(
&self,
cwd: impl AsRef<Path>,
args: &[&str],
envs: &[(&str, &str)],
) -> CommandResult {
let mut cmd = Command::new(&self.binary);
cmd.args(args).current_dir(cwd.as_ref());
for (k, v) in envs {
cmd.env(k, v);
}
command_output(&mut cmd, &format!("tnmsc {}", args.join(" ")))
}

pub fn run_success(&self, args: &[&str]) -> CommandResult {
let result = self.run(args);
result.assert_success(&format!("tnmsc {}", args.join(" ")));
Expand Down Expand Up @@ -341,6 +363,47 @@ impl LocalTestRunner {
}
}

// ---------------------------------------------------------------------------
// Cross-process file lock — prevents test binaries from interfering with each
// other when running local tests on the shared project directory.
// ---------------------------------------------------------------------------

pub struct CrossProcessLock(Option<PathBuf>);

impl Drop for CrossProcessLock {
fn drop(&mut self) {
if let Some(path) = self.0.take() {
let _ = std::fs::remove_file(&path);
}
}
}

fn acquire_cross_process_lock() -> CrossProcessLock {
let lock_path = home_dir().join(".tnmsc_local_test_lock");
loop {
match std::fs::File::create_new(&lock_path) {
Ok(_) => return CrossProcessLock(Some(lock_path)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
// Stale-lock detection: if older than 5 minutes, remove and retry
if let Ok(meta) = std::fs::metadata(&lock_path) {
if let Ok(created) = meta.created() {
if let Ok(elapsed) = created.elapsed() {
if elapsed > Duration::from_secs(300) {
let _ = std::fs::remove_file(&lock_path);
continue;
}
}
}
}
std::thread::sleep(Duration::from_millis(200));
}
Err(_) => {
std::thread::sleep(Duration::from_millis(200));
}
}
}
}

pub fn ensure_binary() {
let binary = binary_path();

Expand Down
3 changes: 3 additions & 0 deletions cli/local-tests/tests/claude_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ fn local_claude_clean_removes_all_project_files() {
let runner = LocalTestRunner::new();
runner.assert_project_ready();

let clean = runner.clean();
clean.assert_success("tnmsc clean before install");

let install = runner.install();
install.assert_success("tnmsc install before clean");

Expand Down
8 changes: 8 additions & 0 deletions cli/local-tests/tests/clean_blackbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ fn local_clean_removes_project_claude_md() {
let runner = LocalTestRunner::new();
runner.assert_project_ready();

// 先 clean 再 install 确保可复现
let clean = runner.clean();
clean.assert_success("tnmsc clean before install");

// 先 install 生成文件
let install = runner.install();
install.assert_success("tnmsc install before clean");
Expand All @@ -45,6 +49,10 @@ fn local_clean_dry_run_does_not_remove_files() {
let runner = LocalTestRunner::new();
runner.assert_project_ready();

// 先 clean 再 install 确保可复现
let clean = runner.clean();
clean.assert_success("tnmsc clean before install");

// 先 install 生成文件
let install = runner.install();
install.assert_success("tnmsc install before dry-run clean");
Expand Down
55 changes: 55 additions & 0 deletions cli/local-tests/tests/logging_clean.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Clean 可观测性测试:验证 clean 命令输出足够的可观测信息。

use tnmsc_local_tests::LocalTestRunner;

#[test]
fn clean_outputs_key_spans_and_events() {
let runner = LocalTestRunner::new();
runner.assert_project_ready();

// 先 install 生成文件,再 clean
let install = runner.install();
install.assert_success("tnmsc install before clean");

let result = runner.run(&["--trace", "clean"]);
result.assert_success("tnmsc --trace clean");

// 验证顶层事件
assert!(
result.stdout.contains("### Running clean"),
"clean should output 'Running clean'. stdout:\n{}",
result.stdout
);

// 验证主要 Span
assert!(
result.stdout.contains("### cleanup.discover started"),
"clean should output 'cleanup.discover' span. stdout:\n{}",
result.stdout
);
assert!(
result.stdout.contains("### cleanup.execute started"),
"clean should output 'cleanup.execute' span. stdout:\n{}",
result.stdout
);
}

#[test]
fn clean_outputs_deletion_summary() {
let runner = LocalTestRunner::new();
runner.assert_project_ready();

// 先 install 生成文件,再 clean
let install = runner.install();
install.assert_success("tnmsc install before clean");

let result = runner.run(&["--info", "clean"]);
result.assert_success("tnmsc --info clean");

// Info 级别应该输出删除摘要
assert!(
result.stdout.contains("Deleted") || result.stdout.contains("No files needed updates"),
"clean should output deletion summary. stdout:\n{}",
result.stdout
);
}
52 changes: 52 additions & 0 deletions cli/local-tests/tests/logging_dry_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! Dry-run 可观测性测试:验证 dry-run 命令输出足够的可观测信息。

use tnmsc_local_tests::LocalTestRunner;

#[test]
fn dry_run_outputs_key_spans_and_events() {
let runner = LocalTestRunner::new();
runner.assert_project_ready();

let result = runner.run(&["--trace", "dry-run"]);
result.assert_success("tnmsc --trace dry-run");

// 验证顶层事件
assert!(
result.stdout.contains("### Running dry-run"),
"dry-run should output 'Running dry-run'. stdout:\n{}",
result.stdout
);

// 验证主要 Span
assert!(
result.stdout.contains("### config.load started"),
"dry-run should output 'config.load' span. stdout:\n{}",
result.stdout
);
assert!(
result.stdout.contains("### context.collect started"),
"dry-run should output 'context.collect' span. stdout:\n{}",
result.stdout
);
assert!(
result.stdout.contains("### output.build started"),
"dry-run should output 'output.build' span. stdout:\n{}",
result.stdout
);
}

#[test]
fn dry_run_outputs_plan_preview() {
let runner = LocalTestRunner::new();
runner.assert_project_ready();

let result = runner.run(&["--info", "dry-run"]);
result.assert_success("tnmsc --info dry-run");

// Info 级别应该输出计划摘要
assert!(
result.stdout.contains("Planned") || result.stdout.contains("No files needed updates"),
"dry-run should output plan summary. stdout:\n{}",
result.stdout
);
}
Loading
Loading