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
88 changes: 68 additions & 20 deletions cli/local-tests/tests/dry_run_smoke.rs
Original file line number Diff line number Diff line change
@@ -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");
}
97 changes: 79 additions & 18 deletions cli/local-tests/tests/logging_dry_run.rs
Original file line number Diff line number Diff line change
@@ -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{}",
Expand All @@ -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{}",
Expand Down
149 changes: 149 additions & 0 deletions cli/local-tests/tests/packaging_smoke.rs
Original file line number Diff line number Diff line change
@@ -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<Path>, target: impl AsRef<Path>) {
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}"
);
}
}
4 changes: 2 additions & 2 deletions doc/app/docs/[[...mdxPath]]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function DocsSidebar({pageMap}: {readonly pageMap: readonly PageMapItem[]}) {
return (
<nav className="docs-sidebar" aria-label="Documentation">
{pageMap.map(item => {
if (item.name == null || item.name === '' || item.route == null) {
if (item.name == null || item.name.length === 0 || item.route == null) {
return null
}

Expand All @@ -41,7 +41,7 @@ export default async function DocsLayout({
const section = firstSegment != null && isDocSectionName(firstSegment)
? firstSegment
: void 0
const pageMap = await getPageMap(section != null ? `/docs/${section}` : '/docs') as readonly PageMapItem[]
const pageMap = await getPageMap(section !== void 0 ? `/docs/${section}` : '/docs') as readonly PageMapItem[]

return (
<div className="docs-shell">
Expand Down
Loading