feat: release Dever 0.1.4 with enforceable project guidance
This commit is contained in:
1 parent
201bdee372
commit
89629c2197
86 files changed
+2798
-338
No files matched your search
@@ -151,6 +151,12 @@ fn application_check_rejects_redundancy_before_preparing_build_resources() {
|
||||
] {
|
||||
assert!(diagnostic.contains(expected), "{expected}: {diagnostic}");
|
||||
}
|
||||
assert_eq!(diagnostic.matches("error[C016]").count(), 3, "{diagnostic}");
|
||||
assert!(!diagnostic.contains("warning[W005]"), "{diagnostic}");
|
||||
assert!(
|
||||
diagnostic.contains("sample/profile/domain.dever:1:1: note:"),
|
||||
"{diagnostic}"
|
||||
);
|
||||
// The same source fails before native packs, output publication or execution.
|
||||
let executable = root.join("must-not-build");
|
||||
for (action, args) in [
|
||||
@@ -166,6 +172,120 @@ fn application_check_rejects_redundancy_before_preparing_build_resources() {
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_check_rejects_proven_duplicates_before_runtime_or_database_access() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
let compiler = compiler(toolchain.path(), "dever");
|
||||
for (domain, app, diagnostic, location, warning) in [
|
||||
(
|
||||
"first(value: Int) (answer: Int) pure { answer = value + 1 }\nsecond(renamed: Int) (answer: Int) pure { answer = renamed + 1 }",
|
||||
"total(value: Int) (answer: Int) { first = domain.first(value)\nsecond = domain.second(value)\nanswer = first + second }",
|
||||
"same implementation and contract",
|
||||
"sample/count/domain.dever:2:1: error[C016]",
|
||||
"warning[W003]",
|
||||
),
|
||||
(
|
||||
"calculate(value: Int) (answer: Int) pure { answer = value + 1 }",
|
||||
"total(value: Int) (answer: Int) { first = domain.calculate(value)\nsecond = domain.calculate(value)\nanswer = first + second }",
|
||||
"earlier result is still available",
|
||||
"sample/count/app.dever:2:",
|
||||
"warning[W004]",
|
||||
),
|
||||
] {
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let root = project_dir.path();
|
||||
write(root, "config/setting.json", "{}");
|
||||
write(root, "module/sample/count/model.dever", "type Count { value: Int }");
|
||||
write(root, "module/sample/count/domain.dever", domain);
|
||||
write(root, "module/sample/count/app.dever", app);
|
||||
write(root, "module/sample/count/api.dever", "cmd total = app.total");
|
||||
let checked = cli(&compiler, root, "check", &[]);
|
||||
for expected in ["C016", diagnostic, location, "previous implementation or computation"] {
|
||||
failure(&checked, expected);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&checked.stderr);
|
||||
assert_eq!(stderr.matches("error[C016]").count(), 1, "{stderr}");
|
||||
assert!(!stderr.contains(warning), "{stderr}");
|
||||
assert_eq!(
|
||||
stderr.matches("note: previous implementation or computation").count(),
|
||||
1,
|
||||
"{stderr}"
|
||||
);
|
||||
let executable = root.join("must-not-build");
|
||||
for (action, arguments) in [
|
||||
("run", vec!["--", "sample.count.total", "{\"value\":1}"]),
|
||||
("build", vec!["--output", executable.to_str().unwrap()]),
|
||||
] {
|
||||
let rejected = cli(&compiler, root, action, &arguments);
|
||||
assert!(!rejected.status.success());
|
||||
assert_eq!(rejected.stderr, checked.stderr);
|
||||
}
|
||||
let snapshot = cli(&compiler, root, "api", &[]);
|
||||
success(&snapshot);
|
||||
let stderr = String::from_utf8_lossy(&snapshot.stderr);
|
||||
assert!(stderr.contains(warning), "{stderr}");
|
||||
assert!(!stderr.contains("C016"), "{stderr}");
|
||||
write(
|
||||
root,
|
||||
"test/sample/count/value.dever",
|
||||
"value() () { assert_eq(app.total(1), 4) }",
|
||||
);
|
||||
let tests = cli(&compiler, root, "test", &[]);
|
||||
failure(&tests, "runtime pack");
|
||||
let stderr = String::from_utf8_lossy(&tests.stderr);
|
||||
assert!(stderr.contains(warning), "{stderr}");
|
||||
assert!(!stderr.contains("C016"), "{stderr}");
|
||||
write(root, "dever.api", "invalid baseline");
|
||||
let baseline = cli(&compiler, root, "check", &[]);
|
||||
failure(&baseline, "invalid API baseline");
|
||||
let stderr = String::from_utf8_lossy(&baseline.stderr);
|
||||
assert!(stderr.contains(warning), "{stderr}");
|
||||
assert!(!stderr.contains("C016"), "{stderr}");
|
||||
assert!(!executable.exists());
|
||||
assert!(!root.join("data").exists());
|
||||
}
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_check_keeps_warnings_with_distinct_evidence_and_other_codes() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
let compiler = compiler(toolchain.path(), "dever");
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let root = project_dir.path();
|
||||
write(root, "config/setting.json", "{}");
|
||||
write(
|
||||
root,
|
||||
"module/sample/count/domain.dever",
|
||||
"orphan(value: Int) (answer: Int) pure { answer = value + 1 }\nfirst(value: Int) (answer: Int) pure { answer = value + 1 }\nsecond(value: Int) (answer: Int) pure { answer = value + 1 }",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/count/app.dever",
|
||||
"total(value: Int) (answer: Int) { unused = 0\nfirst = domain.first(value)\nsecond = domain.second(value)\nanswer = first + second }",
|
||||
);
|
||||
write(root, "module/sample/count/api.dever", "cmd total = app.total");
|
||||
let checked = cli(&compiler, root, "check", &[]);
|
||||
failure(&checked, "C016");
|
||||
let stderr = String::from_utf8_lossy(&checked.stderr);
|
||||
assert_eq!(stderr.matches("error[C016]").count(), 2, "{stderr}");
|
||||
// Warning proof includes the orphan; production proof starts at the first reachable function.
|
||||
assert_eq!(stderr.matches("warning[W003]").count(), 2, "{stderr}");
|
||||
assert_eq!(stderr.matches("warning[W001]").count(), 1, "{stderr}");
|
||||
assert_eq!(
|
||||
stderr.matches("sample/count/domain.dever:1:1: note:").count(),
|
||||
2,
|
||||
"{stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
stderr.matches("sample/count/domain.dever:2:1: note:").count(),
|
||||
1,
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(!root.join("data").exists());
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_check_rejects_plain_model_get_wrapper_without_opening_database() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
@@ -303,6 +423,68 @@ fn application_check_does_not_apply_production_reachability_to_tests() {
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_still_checks_unselected_sources_and_reports_no_match_before_compilation() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
let compiler = compiler(toolchain.path(), "dever");
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let root = project_dir.path();
|
||||
project(root);
|
||||
write(root, "test/sample/echo/first.dever", "first() () { assert_eq(1, 1) }");
|
||||
write(root, "test/sample/echo/second.dever", "second() () { assert_eq(1, 1) }");
|
||||
for filter in ["sam", "sample.ec", "sample.echo.fir", "sample.echo.missing"] {
|
||||
let result = cli(&compiler, root, "test", &["--filter", filter]);
|
||||
failure(&result, "no tests match filter");
|
||||
assert!(!String::from_utf8_lossy(&result.stdout).contains("runtime pack"));
|
||||
}
|
||||
let selected = cli(&compiler, root, "test", &["--filter", "sample.echo.second"]);
|
||||
failure(&selected, "runtime pack");
|
||||
let report = String::from_utf8_lossy(&selected.stdout);
|
||||
assert!(report.contains("0 passed; 1 failed; 1 not run"), "{report}");
|
||||
assert!(!report.contains("test sample/echo/first ..."), "{report}");
|
||||
write(root, "test/sample/echo/first.dever", "first() () { missing() }");
|
||||
failure(&cli(&compiler, root, "test", &["--filter", "sample.echo.second"]), "C004");
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an explicitly prepared runtime pack adjacent to CARGO_BIN_EXE_dever; owned short-lived cases only"]
|
||||
fn llvm_test_controls_preserve_indices_prefix_boundaries_and_timeout_continuation() {
|
||||
let compiler = Path::new(env!("CARGO_BIN_EXE_dever"));
|
||||
let directory = TemporaryDirectory::new();
|
||||
let root = directory.path();
|
||||
project(root);
|
||||
for (relative, source) in [
|
||||
("test/sample/echo/alpha.dever", "alpha() () { assert_eq(1, 2) }"),
|
||||
("test/sample/echo/beta.dever", "beta() () { assert_eq(7, 7) }"),
|
||||
("test/sample/echo/slow.dever", "slow() () { dever.task.sleep(30000) }"),
|
||||
("test/sample/echo/zeta.dever", "zeta() () { assert_eq(9, 9) }"),
|
||||
("test/sample/echoes/first.dever", "first() () { assert_eq(1, 2) }"),
|
||||
("test/samples/echo/first.dever", "first() () { assert_eq(1, 2) }"),
|
||||
] {
|
||||
write(root, relative, source);
|
||||
}
|
||||
let selected = cli(compiler, root, "test", &["--filter", "sample.echo.beta"]);
|
||||
success(&selected);
|
||||
let report = String::from_utf8_lossy(&selected.stdout);
|
||||
assert!(report.contains("test sample/echo/beta ... ok"), "{report}");
|
||||
assert!(report.contains("1 passed; 0 failed; 5 not run"), "{report}");
|
||||
assert!(!report.contains("sample/echo/alpha"), "{report}");
|
||||
|
||||
for (filter, counts) in [
|
||||
("sample.echo", "2 passed; 2 failed; 2 not run"),
|
||||
("sample", "2 passed; 3 failed; 1 not run"),
|
||||
] {
|
||||
let selected = cli(compiler, root, "test", &["--filter", filter, "--timeout-ms", "250"]);
|
||||
failure(&selected, counts);
|
||||
let report = String::from_utf8_lossy(&selected.stdout);
|
||||
assert!(report.contains("test process timed out after 250 ms"), "{report}");
|
||||
assert!(report.contains("test sample/echo/zeta ... ok"), "{report}");
|
||||
assert!(!report.contains("test samples/echo/first"), "{report}");
|
||||
}
|
||||
failure(&cli(compiler, root, "test", &["--filter", "sample.echo.bet"]), "no tests match filter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_build_requires_the_selected_pack_even_when_host_pack_exists() {
|
||||
let root = TemporaryDirectory::new();
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
use super::*;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
use std::process::Output;
|
||||
|
||||
struct Project {
|
||||
temporary: TemporaryDirectory,
|
||||
layout: Layout,
|
||||
root: PathBuf,
|
||||
key: Ed25519KeyPair,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
fn new() -> Self {
|
||||
let temporary = TemporaryDirectory::new();
|
||||
let layout = Layout::new(temporary.path().join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let key = signing_key();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
format!("{}\n", hex(key.public_key().as_ref())),
|
||||
)
|
||||
.unwrap();
|
||||
write_release(
|
||||
&layout,
|
||||
&key,
|
||||
"1.0.0",
|
||||
&temporary.path().join("dispatch"),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
MachineManager::new(layout.clone())
|
||||
.install_requested("1.0.0")
|
||||
.unwrap();
|
||||
fs::copy(env!("CARGO_BIN_EXE_dever-launcher"), layout.bin().join("dever")).unwrap();
|
||||
let root = temporary.path().join("project");
|
||||
fs::create_dir_all(root.join("module")).unwrap();
|
||||
fs::create_dir(root.join("config")).unwrap();
|
||||
fs::write(root.join("config/setting.json"), "{}").unwrap();
|
||||
Self {
|
||||
temporary,
|
||||
layout,
|
||||
root,
|
||||
key,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self, root: &Path, extra: &[&str]) -> Output {
|
||||
Command::new(self.layout.bin().join("dever"))
|
||||
.args(["skill", "install"])
|
||||
.args(extra)
|
||||
.current_dir(root)
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn succeeds(&self) {
|
||||
let output = self.run(&self.root, &[]);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("AGENTS.md"));
|
||||
self.assert_no_staging();
|
||||
}
|
||||
|
||||
fn fails(&self, message: &str) {
|
||||
let output = self.run(&self.root, &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains(message),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
self.assert_no_staging();
|
||||
}
|
||||
|
||||
fn assert_no_staging(&self) {
|
||||
assert!(fs::read_dir(&self.root).unwrap().all(|entry| {
|
||||
!entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".dever-skill-")
|
||||
}));
|
||||
}
|
||||
|
||||
fn sign_manifest(&self, manifest: &ReleaseManifest) {
|
||||
let release = self.layout.versions().join("1.0.0");
|
||||
let bytes = serde_json::to_vec(manifest).unwrap();
|
||||
fs::write(release.join("manifest.json"), &bytes).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
hex(self.key.sign(&bytes).as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skill_preserves_user_bytes_permissions_and_updates_only_its_block() {
|
||||
let fixture = Project::new();
|
||||
let agents = fixture.root.join("AGENTS.md");
|
||||
let before = "# 项目规则\r\n保留用户内容,无末尾换行";
|
||||
fs::write(&agents, before).unwrap();
|
||||
fs::set_permissions(&agents, fs::Permissions::from_mode(0o640)).unwrap();
|
||||
fixture.succeeds();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
format!("{before}\n\n{}", project_rules("1.0.0"))
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&agents).unwrap().permissions().mode() & 0o777,
|
||||
0o640
|
||||
);
|
||||
let installed = fs::read(&agents).unwrap();
|
||||
fixture.succeeds();
|
||||
assert_eq!(fs::read(&agents).unwrap(), installed);
|
||||
|
||||
let prefix = "# 自定义前文\r\n\r\n";
|
||||
let suffix = "\r\n# 自定义后文\r\n原样保留";
|
||||
fs::write(
|
||||
&agents,
|
||||
format!("{prefix}<!-- dever-language:start -->\n旧规则\n<!-- dever-language:end -->{suffix}"),
|
||||
)
|
||||
.unwrap();
|
||||
fixture.succeeds();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
format!("{prefix}{}{suffix}", project_rules("1.0.0").trim_end())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skill_rejects_ambiguous_or_unsafe_agents_without_changes() {
|
||||
let fixture = Project::new();
|
||||
let agents = fixture.root.join("AGENTS.md");
|
||||
for content in [
|
||||
"<!-- dever-language:start -->",
|
||||
"<!-- dever-language:end -->",
|
||||
"<!-- dever-language:end --><!-- dever-language:start -->",
|
||||
"<!-- dever-language:start --><!-- dever-language:start --><!-- dever-language:end -->",
|
||||
"<!-- dever-language:start --><!-- dever-language:end --><!-- dever-language:start --><!-- dever-language:end -->",
|
||||
] {
|
||||
fs::write(&agents, content).unwrap();
|
||||
fixture.fails("exactly one complete");
|
||||
assert_eq!(fs::read_to_string(&agents).unwrap(), content);
|
||||
}
|
||||
fs::write(&agents, [0xff, 0xfe]).unwrap();
|
||||
fixture.fails("UTF-8");
|
||||
assert_eq!(fs::read(&agents).unwrap(), [0xff, 0xfe]);
|
||||
fs::write(&agents, "do not overwrite").unwrap();
|
||||
fs::set_permissions(&agents, fs::Permissions::from_mode(0o444)).unwrap();
|
||||
fixture.fails("read-only");
|
||||
fs::set_permissions(&agents, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
fs::remove_file(&agents).unwrap();
|
||||
fs::create_dir(&agents).unwrap();
|
||||
fixture.fails("regular file");
|
||||
fs::remove_dir(&agents).unwrap();
|
||||
let other = fixture.temporary.path().join("other-agents");
|
||||
fs::write(&other, "untouched").unwrap();
|
||||
symlink(&other, &agents).unwrap();
|
||||
fixture.fails("regular file");
|
||||
assert!(fs::symlink_metadata(&agents).unwrap().is_symlink());
|
||||
assert_eq!(fs::read_to_string(&other).unwrap(), "untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skill_requires_project_root_valid_version_and_signed_template() {
|
||||
let fixture = Project::new();
|
||||
let agents = fixture.root.join("AGENTS.md");
|
||||
let output = fixture.run(&fixture.root, &["unwanted-directory"]);
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("accepts no arguments"));
|
||||
assert!(!fixture.root.join("unwanted-directory").exists());
|
||||
let output = fixture.run(fixture.temporary.path(), &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(!fixture.temporary.path().join("AGENTS.md").exists());
|
||||
fs::remove_dir(fixture.root.join("module")).unwrap();
|
||||
symlink(fixture.temporary.path(), fixture.root.join("module")).unwrap();
|
||||
fixture.fails("real module/");
|
||||
fs::remove_file(fixture.root.join("module")).unwrap();
|
||||
fs::create_dir(fixture.root.join("module")).unwrap();
|
||||
let settings = fixture.root.join("config/setting.json");
|
||||
for invalid in [
|
||||
"not json",
|
||||
"[]",
|
||||
r#"{"dever":{"version":"latest"}}"#,
|
||||
r#"{"dever":{"version":"9.0.0"}}"#,
|
||||
] {
|
||||
fs::write(&settings, invalid).unwrap();
|
||||
let output = fixture.run(&fixture.root, &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(!agents.exists());
|
||||
}
|
||||
fs::remove_file(&settings).unwrap();
|
||||
fixture.fails("cannot read");
|
||||
fs::write(&settings, "{}").unwrap();
|
||||
let template = fixture
|
||||
.layout
|
||||
.versions()
|
||||
.join("1.0.0/skills/dever-language/assets/template/AGENTS.md");
|
||||
fs::write(&template, "tampered instructions").unwrap();
|
||||
let output = fixture.run(&fixture.root, &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(!agents.exists());
|
||||
fixture.assert_no_staging();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skill_refuses_unsigned_extra_or_invalid_signed_template() {
|
||||
let fixture = Project::new();
|
||||
let agents = fixture.root.join("AGENTS.md");
|
||||
fs::write(&agents, "user-owned instructions").unwrap();
|
||||
let release = fixture.layout.versions().join("1.0.0");
|
||||
let mut manifest: ReleaseManifest =
|
||||
serde_json::from_slice(&fs::read(release.join("manifest.json")).unwrap()).unwrap();
|
||||
let logical = "skills/dever-language/assets/template/AGENTS.md";
|
||||
manifest.artifacts.retain(|entry| entry.path != logical);
|
||||
fixture.sign_manifest(&manifest);
|
||||
fixture.fails("does not declare resource");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
"user-owned instructions"
|
||||
);
|
||||
|
||||
let template = release.join(logical);
|
||||
for invalid in [
|
||||
"no managed block",
|
||||
"<!-- dever-language:start -->missing end",
|
||||
"outside\n<!-- dever-language:start -->rules<!-- dever-language:end -->",
|
||||
] {
|
||||
fs::write(&template, invalid).unwrap();
|
||||
manifest.artifacts.retain(|entry| entry.path != logical);
|
||||
manifest.artifacts.push(artifact(&template, logical));
|
||||
fixture.sign_manifest(&manifest);
|
||||
let output = fixture.run(&fixture.root, &[]);
|
||||
assert!(!output.status.success());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
"user-owned instructions"
|
||||
);
|
||||
fixture.assert_no_staging();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0);
|
||||
#[path = "shared_toolchain_extensions.rs"]
|
||||
mod extensions;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "project_skill.rs"]
|
||||
mod project_skill;
|
||||
|
||||
struct TemporaryDirectory(PathBuf);
|
||||
|
||||
impl TemporaryDirectory {
|
||||
@@ -306,30 +310,16 @@ fn signed_versions_install_select_dispatch_update_and_rollback_atomically() {
|
||||
fs::read_to_string(skill_path(&layout).join("SKILL.md")).unwrap(),
|
||||
"skill 1.0.0\n"
|
||||
);
|
||||
let skill_entry = temporary.path().join("dever-language");
|
||||
execute_in(
|
||||
&layout,
|
||||
&[
|
||||
"skill".into(),
|
||||
"install".into(),
|
||||
skill_entry.as_os_str().into(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let loader = fs::read(skill_entry.join("SKILL.md")).unwrap();
|
||||
assert!(String::from_utf8_lossy(&loader).contains("skill path"));
|
||||
assert!(
|
||||
execute_in(
|
||||
&layout,
|
||||
&[
|
||||
"skill".into(),
|
||||
"install".into(),
|
||||
skill_entry.as_os_str().into()
|
||||
]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(fs::read(skill_entry.join("SKILL.md")).unwrap(), loader);
|
||||
fs::copy(env!("CARGO_BIN_EXE_dever-launcher"), layout.bin().join("dever")).unwrap();
|
||||
let active_project = temporary.path().join("active-project");
|
||||
fs::create_dir_all(active_project.join("module")).unwrap();
|
||||
fs::create_dir(active_project.join("config")).unwrap();
|
||||
fs::write(active_project.join("config/setting.json"), "{}").unwrap();
|
||||
install_project_skill(&layout, &active_project);
|
||||
let rules = fs::read(active_project.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(rules, project_rules("1.0.0").as_bytes());
|
||||
install_project_skill(&layout, &active_project);
|
||||
assert_eq!(fs::read(active_project.join("AGENTS.md")).unwrap(), rules);
|
||||
|
||||
let new_project = temporary.path().join("not-created-yet");
|
||||
let CommandResult::Exited(code) = execute_in(
|
||||
@@ -351,6 +341,7 @@ fn signed_versions_install_select_dispatch_update_and_rollback_atomically() {
|
||||
|
||||
let project = temporary.path().join("project");
|
||||
fs::create_dir_all(project.join("config")).unwrap();
|
||||
fs::create_dir(project.join("module")).unwrap();
|
||||
fs::write(
|
||||
project.join("config/setting.json"),
|
||||
r#"{"dever":{"version":"2.0.0"}}"#,
|
||||
@@ -368,6 +359,11 @@ fn signed_versions_install_select_dispatch_update_and_rollback_atomically() {
|
||||
Path::new(&pinned_skill),
|
||||
layout.versions().join("2.0.0/skills/dever-language")
|
||||
);
|
||||
install_project_skill(&layout, &project);
|
||||
assert_eq!(
|
||||
fs::read_to_string(project.join("AGENTS.md")).unwrap(),
|
||||
project_rules("2.0.0")
|
||||
);
|
||||
let CommandResult::Exited(code) = execute_in(&layout, &arguments).unwrap() else {
|
||||
panic!("project command must dispatch")
|
||||
};
|
||||
@@ -393,7 +389,12 @@ fn signed_versions_install_select_dispatch_update_and_rollback_atomically() {
|
||||
fs::read_to_string(skill_path(&layout).join("SKILL.md")).unwrap(),
|
||||
"skill 2.0.0\n"
|
||||
);
|
||||
assert_eq!(fs::read(skill_entry.join("SKILL.md")).unwrap(), loader);
|
||||
assert_eq!(fs::read(active_project.join("AGENTS.md")).unwrap(), rules);
|
||||
install_project_skill(&layout, &active_project);
|
||||
assert_eq!(
|
||||
fs::read_to_string(active_project.join("AGENTS.md")).unwrap(),
|
||||
project_rules("2.0.0")
|
||||
);
|
||||
fs::write(layout.downloads().join("latest"), "1.0.0\n").unwrap();
|
||||
assert!(
|
||||
manager
|
||||
@@ -1342,12 +1343,16 @@ fn write_release(
|
||||
let skill = root.join("skills/dever-language/SKILL.md");
|
||||
fs::create_dir_all(skill.parent().unwrap()).unwrap();
|
||||
fs::write(&skill, format!("skill {version}\n")).unwrap();
|
||||
let rules = root.join("skills/dever-language/assets/template/AGENTS.md");
|
||||
fs::create_dir_all(rules.parent().unwrap()).unwrap();
|
||||
fs::write(&rules, project_rules(version)).unwrap();
|
||||
let manifest = ReleaseManifest::new(
|
||||
Version::parse(version).unwrap(),
|
||||
vec![
|
||||
artifact(&core, "dever-core"),
|
||||
artifact(&runtime, "runtime.pack"),
|
||||
artifact(&skill, "skills/dever-language/SKILL.md"),
|
||||
artifact(&rules, "skills/dever-language/assets/template/AGENTS.md"),
|
||||
],
|
||||
);
|
||||
let bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
@@ -1359,6 +1364,29 @@ fn write_release(
|
||||
fs::write(root.join("manifest.sig"), format!("{}\n", hex(&signature))).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn project_rules(version: &str) -> String {
|
||||
include_str!("../../skills/assets/template/AGENTS.md").replace(
|
||||
"<!-- dever-language:end -->",
|
||||
&format!("<!-- fixture version {version} -->\n<!-- dever-language:end -->"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn install_project_skill(layout: &Layout, project: &Path) {
|
||||
let output = Command::new(layout.bin().join("dever"))
|
||||
.args(["skill", "install"])
|
||||
.current_dir(project)
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn artifact(path: &Path, relative: &str) -> Artifact {
|
||||
let bytes = fs::read(path).unwrap();
|
||||
Artifact {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::{Action, OsString, arguments_for};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_options_default_to_sixty_seconds_and_accept_either_order() {
|
||||
for options in [
|
||||
vec![],
|
||||
vec!["--filter", "sample.account.case_2"],
|
||||
vec!["--filter", "sample.account", "--timeout-ms", "15"],
|
||||
vec!["--timeout-ms", "15", "--filter", "sample"],
|
||||
vec!["--timeout-ms", "18446744073709551615"],
|
||||
] {
|
||||
let arguments: Vec<_> = [vec!["test", "project"], options.clone()]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
let (root, action) = arguments_for(&arguments).unwrap();
|
||||
assert_eq!(root.to_str(), Some("project"));
|
||||
let Action::Test(parsed) = action else {
|
||||
panic!("expected test")
|
||||
};
|
||||
let timeout = options
|
||||
.iter()
|
||||
.position(|option| *option == "--timeout-ms")
|
||||
.map(|index| options[index + 1].parse::<u64>().unwrap())
|
||||
.unwrap_or(60_000);
|
||||
assert_eq!(parsed.timeout, Duration::from_millis(timeout));
|
||||
assert_eq!(
|
||||
parsed.filter.as_deref(),
|
||||
options
|
||||
.iter()
|
||||
.position(|option| *option == "--filter")
|
||||
.map(|index| options[index + 1]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_options_reject_invalid_missing_duplicate_and_unknown_arguments() {
|
||||
let mut invalid = vec![
|
||||
vec!["--filter"],
|
||||
vec!["--timeout-ms"],
|
||||
vec!["--filter", "--timeout-ms", "20"],
|
||||
vec!["--filter", "sample", "--filter", "sample.account"],
|
||||
vec!["--timeout-ms", "20", "--timeout-ms", "30"],
|
||||
vec!["--unknown", "value"],
|
||||
vec!["extra"],
|
||||
];
|
||||
for filter in [
|
||||
"", ".sample", "sample.", "sample..case", "sample.a.b.c", "sam*", "sample/account",
|
||||
"Sample", "sample.__case", "sample.case_", "sample.2case", " sample", "用户",
|
||||
] {
|
||||
invalid.push(vec!["--filter", filter]);
|
||||
}
|
||||
for timeout in [
|
||||
"", "0", "-1", "+1", "1.5", "1ms", " 1", "18446744073709551616",
|
||||
] {
|
||||
invalid.push(vec!["--timeout-ms", timeout]);
|
||||
}
|
||||
for options in invalid {
|
||||
let arguments: Vec<_> = [vec!["test", "project"], options]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
assert!(arguments_for(&arguments).is_err(), "accepted {arguments:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_options_reject_non_utf8_values() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
for option in ["--filter", "--timeout-ms"] {
|
||||
let arguments = [
|
||||
OsString::from("test"),
|
||||
OsString::from("project"),
|
||||
OsString::from(option),
|
||||
OsString::from_vec(vec![0xff]),
|
||||
];
|
||||
assert!(arguments_for(&arguments).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use super::{TemporaryProject, process};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DESCENDANTS: &str = r#"
|
||||
/bin/sh -c 'echo $$ > descendant; /bin/sleep 30 & echo $! > grandchild; wait' &
|
||||
while [ ! -s grandchild ]; do :; done
|
||||
"#;
|
||||
|
||||
// subreaper 是进程级状态;此单例测试顺序覆盖所有真实进程场景。
|
||||
#[test]
|
||||
fn case_process_captures_output_times_out_and_reaps_only_owned_descendants() {
|
||||
let original_reaper = rustix::process::child_subreaper().unwrap();
|
||||
let mut unrelated = Command::new("/bin/sleep").arg("30").spawn().unwrap();
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
for (body, descendants, expected) in [
|
||||
("printf hidden; printf hidden-error >&2; exit 0", false, None),
|
||||
(
|
||||
"printf output; printf diagnostic >&2; exit 7",
|
||||
false,
|
||||
Some("exit status: 7"),
|
||||
),
|
||||
(
|
||||
"printf before-timeout; /bin/sleep 30",
|
||||
false,
|
||||
Some("timed out after 100 ms"),
|
||||
),
|
||||
("exit 0", true, None),
|
||||
("printf owned-error >&2; exit 7", true, Some("exit status: 7")),
|
||||
("printf started; wait", true, Some("timed out after 100 ms")),
|
||||
] {
|
||||
let root = TemporaryProject::new().unwrap();
|
||||
let started = Instant::now();
|
||||
let script = if descendants {
|
||||
format!("{DESCENDANTS}\n{body}")
|
||||
} else {
|
||||
body.to_owned()
|
||||
};
|
||||
let result = process::run(
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&script)
|
||||
.env_clear()
|
||||
.current_dir(root.path()),
|
||||
root.path(),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
match expected {
|
||||
None => result.unwrap(),
|
||||
Some(message) => {
|
||||
let failure = result.unwrap_err();
|
||||
assert!(failure.message.contains(message), "{failure:?}");
|
||||
if script.contains("printf output") {
|
||||
assert_eq!(failure.stdout, b"output");
|
||||
assert_eq!(failure.stderr, b"diagnostic");
|
||||
}
|
||||
if script.contains("printf before-timeout") {
|
||||
assert_eq!(failure.stdout, b"before-timeout");
|
||||
}
|
||||
if script.contains("printf owned-error") {
|
||||
assert_eq!(failure.stderr, b"owned-error");
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in ["descendant", "grandchild"] {
|
||||
let path = root.path().join(name);
|
||||
if descendants {
|
||||
let pid = fs::read_to_string(path)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!std::path::Path::new(&format!("/proc/{pid}")).exists(),
|
||||
"unreaped {name} {pid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(rustix::process::child_subreaper().unwrap(), original_reaper);
|
||||
}
|
||||
|
||||
let root = TemporaryProject::new().unwrap();
|
||||
let failure = process::run(
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg("/bin/dd if=/dev/zero bs=131072 count=2 2>/dev/null; printf end >&2; exit 9")
|
||||
.env_clear(),
|
||||
root.path(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(failure.message.contains("exit status: 9"));
|
||||
assert_eq!(failure.stdout.len(), 262_144);
|
||||
assert_eq!(failure.stderr, b"end");
|
||||
|
||||
let root = TemporaryProject::new().unwrap();
|
||||
let failure = process::run(
|
||||
&mut Command::new(root.path().join("missing-executable")),
|
||||
root.path(),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(failure.message.contains("cannot run test process"));
|
||||
assert_eq!(rustix::process::child_subreaper().unwrap(), original_reaper);
|
||||
});
|
||||
// 即使断言失败也回收此对照进程,不能让测试自身遗留资源。
|
||||
let unrelated_running = unrelated.try_wait().unwrap().is_none();
|
||||
let _ = unrelated.kill();
|
||||
unrelated.wait().unwrap();
|
||||
result.unwrap();
|
||||
assert!(unrelated_running, "case cleanup affected an unrelated child");
|
||||
}
|
||||
Reference in new issue
Block a user