diff --git a/cli/local-tests/tests/dry_run_smoke.rs b/cli/local-tests/tests/dry_run_smoke.rs index 4a14644b..88525b2f 100644 --- a/cli/local-tests/tests/dry_run_smoke.rs +++ b/cli/local-tests/tests/dry_run_smoke.rs @@ -1,31 +1,79 @@ -//! 本地裸机 dry-run 测试:验证 tnmsc dry-run 不写入文件。 -//! -//! **前提**:项目已配置。 +//! Isolated black-box test for the `tnmsc dry-run` no-write guarantee. + +use std::fs; +use std::path::Path; use tnmsc_local_tests::LocalTestRunner; -/// Verify that `tnmsc dry-run` reports what would be written but does NOT create -/// any project files. The core safety guarantee of dry-run mode. #[test] fn local_dry_run_does_not_write_project_files() { - let runner = LocalTestRunner::new(); - runner.assert_project_ready(); - - // 先 clean 确保干净 - let clean = runner.clean(); - clean.assert_success("tnmsc clean before dry-run"); + let temp_root = std::env::temp_dir().join(format!( + "tnmsc-local-dry-run-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let temp_home = temp_root.join("home"); + let workspace_dir = temp_root.join("workspace"); + let project_dir = workspace_dir.join("memory-sync"); + let prompt_dir = workspace_dir.join("aindex").join("app").join("memory-sync"); - assert!( - !runner.file_exists("CLAUDE.md"), - "~/workspace/memory-sync/CLAUDE.md should not exist before dry-run" - ); + fs::create_dir_all(temp_home.join(".aindex")).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + fs::create_dir_all(&prompt_dir).unwrap(); + init_git_repo(&project_dir); + fs::write(prompt_dir.join("agt.mdx"), "# Project memory\n").unwrap(); + fs::write( + temp_home.join(".aindex").join(".tnmsc.json"), + serde_json::json!({ + "workspaceDir": workspace_dir.to_string_lossy(), + "plugins": { + "agentsMd": false, + "git": false, + "readme": false, + "vscode": false, + "zed": false, + "jetbrains": false, + "jetbrainsCodeStyle": false, + "claudeCode": true, + "codex": false, + "cursor": false, + "droid": false, + "gemini": false, + "kiro": false, + "opencode": false, + "qoder": false, + "trae": false, + "traeCn": false, + "windsurf": false + } + }) + .to_string(), + ) + .unwrap(); - // dry-run 不应写入文件 - let dry = runner.dry_run(); - dry.assert_success("tnmsc dry-run"); + let runner = LocalTestRunner::with_cwd(&project_dir); + let temp_home_string = temp_home.to_string_lossy().into_owned(); + let claude_path = project_dir.join("CLAUDE.md"); + assert!(!claude_path.exists()); + runner + .run_at_with_env(&project_dir, &["dry-run"], &[("HOME", &temp_home_string)]) + .assert_success("isolated tnmsc dry-run"); assert!( - !runner.file_exists("CLAUDE.md"), - "~/workspace/memory-sync/CLAUDE.md should not be created by dry-run" + !claude_path.exists(), + "project CLAUDE.md should not be created by dry-run" ); } + +fn init_git_repo(project_dir: &Path) { + let output = std::process::Command::new("git") + .arg("init") + .arg("--quiet") + .current_dir(project_dir) + .output() + .unwrap(); + assert!(output.status.success(), "git init should succeed"); +} diff --git a/cli/local-tests/tests/logging_dry_run.rs b/cli/local-tests/tests/logging_dry_run.rs index fa4a656e..f0cf7196 100644 --- a/cli/local-tests/tests/logging_dry_run.rs +++ b/cli/local-tests/tests/logging_dry_run.rs @@ -1,25 +1,90 @@ -//! Dry-run 可观测性测试:验证 dry-run 命令输出足够的可观测信息。 +//! Dry-run observability tests for isolated local fixtures. + +use std::fs; +use std::path::PathBuf; use tnmsc_local_tests::LocalTestRunner; -/// Verify that `--trace` dry-run outputs all major spans: -/// config.load, context.collect, output.build. +struct IsolatedLoggingDryRunFixture { + runner: LocalTestRunner, + temp_home: PathBuf, + project_dir: PathBuf, +} + +impl IsolatedLoggingDryRunFixture { + fn new() -> Self { + let temp_root = std::env::temp_dir().join(format!( + "tnmsc-local-logging-dry-run-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let temp_home = temp_root.join("home"); + let workspace_dir = temp_root.join("workspace"); + let project_dir = workspace_dir.join("memory-sync"); + let aindex_dir = workspace_dir.join("aindex"); + + fs::create_dir_all(temp_home.join(".aindex")).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + fs::create_dir_all(&aindex_dir).unwrap(); + fs::write( + temp_home.join(".aindex").join(".tnmsc.json"), + serde_json::json!({ + "workspaceDir": workspace_dir.to_string_lossy(), + "plugins": { + "agentsMd": false, + "git": false, + "readme": false, + "vscode": false, + "zed": false, + "jetbrains": false, + "jetbrainsCodeStyle": false, + "claudeCode": true, + "codex": false, + "cursor": false, + "droid": false, + "gemini": false, + "kiro": false, + "opencode": false, + "qoder": false, + "trae": false, + "traeCn": false, + "windsurf": false + } + }) + .to_string(), + ) + .unwrap(); + fs::write(aindex_dir.join("workspace.mdx"), "# Workspace memory\n").unwrap(); + + Self { + runner: LocalTestRunner::with_cwd(&project_dir), + temp_home, + project_dir, + } + } + + fn run(&self, args: &[&str]) -> tnmsc_local_tests::CommandResult { + let temp_home = self.temp_home.to_string_lossy().into_owned(); + self + .runner + .run_at_with_env(&self.project_dir, args, &[("HOME", &temp_home)]) + } +} + #[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"); + let fixture = IsolatedLoggingDryRunFixture::new(); + let result = fixture.run(&["--trace", "dry-run"]); + result.assert_success("isolated 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{}", @@ -37,16 +102,12 @@ fn dry_run_outputs_key_spans_and_events() { ); } -/// Verify that `--info` dry-run outputs a plan summary (what files would be written). #[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"); + let fixture = IsolatedLoggingDryRunFixture::new(); + let result = fixture.run(&["--info", "dry-run"]); + result.assert_success("isolated 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{}", diff --git a/cli/local-tests/tests/packaging_smoke.rs b/cli/local-tests/tests/packaging_smoke.rs new file mode 100644 index 00000000..b5f70499 --- /dev/null +++ b/cli/local-tests/tests/packaging_smoke.rs @@ -0,0 +1,149 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after the Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("tnmsc-packaging-{}-{nonce}", std::process::id())); + fs::create_dir_all(&path).expect("packaging temp dir should be created"); + Self(path) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn host_package() -> (&'static str, &'static str) { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86_64") => ("win32-x64-msvc", "tnmsc.exe"), + ("linux", "x86_64") => ("linux-x64-gnu", "tnmsc"), + ("linux", "aarch64") => ("linux-arm64-gnu", "tnmsc"), + ("macos", "x86_64") => ("darwin-x64", "tnmsc"), + ("macos", "aarch64") => ("darwin-arm64", "tnmsc"), + (os, arch) => panic!("unsupported packaging smoke host: {os}-{arch}"), + } +} + +fn npm() -> &'static str { + if cfg!(windows) { "npm.cmd" } else { "npm" } +} + +fn copy_file(source: impl AsRef, target: impl AsRef) { + let target = target.as_ref(); + fs::create_dir_all(target.parent().expect("target should have a parent")) + .expect("target parent should be created"); + fs::copy(source, target).expect("package file should be copied"); +} + +fn run(command: &mut Command, context: &str) -> std::process::Output { + let output = command + .output() + .unwrap_or_else(|error| panic!("{context} should start: {error}")); + assert!( + output.status.success(), + "{context} should succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn pack(package_dir: &Path, output_dir: &Path) -> PathBuf { + let output = run( + Command::new(npm()) + .args(["pack", "--json", "--pack-destination"]) + .arg(output_dir) + .current_dir(package_dir), + "npm pack", + ); + let reports: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("npm pack output should be JSON"); + let filename = reports[0]["filename"] + .as_str() + .expect("npm pack report should include filename"); + output_dir.join(filename) +} + +#[test] +fn packaging_smoke_covers_release_binary_and_global_install() { + let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("local-tests should be nested under cli") + .to_path_buf(); + let source_root = workspace_root.join("cli"); + let (suffix, binary_name) = host_package(); + let temp = TempDir::new(); + let staged = temp.0.join("staged"); + let platform_dir = staged.join("npm").join(suffix); + let tarballs = temp.0.join("tarballs"); + let install_root = temp.0.join("install"); + fs::create_dir_all(&tarballs).expect("tarball dir should be created"); + + copy_file( + source_root.join("package.json"), + staged.join("package.json"), + ); + copy_file( + source_root.join("bin/tnmsc.js"), + staged.join("bin/tnmsc.js"), + ); + copy_file( + source_root.join("schema/tnmsc.schema.json"), + staged.join("schema/tnmsc.schema.json"), + ); + copy_file( + source_root.join("npm").join(suffix).join("package.json"), + platform_dir.join("package.json"), + ); + + let release_binary = workspace_root.join("target/release").join(binary_name); + assert!( + release_binary.is_file(), + "release binary missing at {}; run cargo build --release -p tnmsc first", + release_binary.display() + ); + run( + Command::new(&release_binary) + .args(["assemble-npm", "--profile", "release"]) + .env("TNMSC_NPM_PACKAGE_ROOT", &staged) + .env("TNMSC_WORKSPACE_ROOT", &workspace_root) + .current_dir(&workspace_root), + "tnmsc assemble-npm", + ); + + let platform_tarball = pack(&platform_dir, &tarballs); + let main_tarball = pack(&staged, &tarballs); + run( + Command::new(npm()) + .arg("install") + .arg("--ignore-scripts") + .arg("--prefix") + .arg(&install_root) + .arg(&main_tarball) + .arg(&platform_tarball), + "isolated npm install", + ); + + let shim = install_root + .join("node_modules/.bin") + .join(if cfg!(windows) { "tnmsc.cmd" } else { "tnmsc" }); + let help = run(Command::new(&shim).arg("help"), "installed tnmsc help"); + let stdout = String::from_utf8_lossy(&help.stdout); + for subcommand in tnmsc_local_tests::EXPECTED_SUBCOMMANDS { + assert!( + stdout.contains(subcommand), + "help should include {subcommand}: {stdout}" + ); + } +} diff --git a/doc/app/docs/[[...mdxPath]]/layout.tsx b/doc/app/docs/[[...mdxPath]]/layout.tsx index ee369d1f..2eccf941 100644 --- a/doc/app/docs/[[...mdxPath]]/layout.tsx +++ b/doc/app/docs/[[...mdxPath]]/layout.tsx @@ -15,7 +15,7 @@ function DocsSidebar({pageMap}: {readonly pageMap: readonly PageMapItem[]}) { return (