985 lines
35 KiB
Rust
985 lines
35 KiB
Rust
#[path = "support/temp.rs"]
|
|
mod temp;
|
|
|
|
use dever_core::source::SourceMap;
|
|
use std::{fs, process::Command};
|
|
use temp::TemporaryDirectory;
|
|
|
|
const APP: &str = "type ReadResult {\n Read(value: Int)\n error Unavailable(message: Text)\n}\nread() (value: Int) { value = port.read() }\n";
|
|
const PORT: &str = "read() (value: Int) fails app.ReadResult\n";
|
|
const ADAPTER: &str = "port.read() (value: Int) { value = 7 }";
|
|
const COMMAND_PORT: &str = "execute(args: List<Text>, stdin: Bytes?) (output: dever.process.Output) fails dever.process.Error";
|
|
const COMMAND_APP: &str = "read() (value: Int) { output = port.execute([\"argument with spaces\"], null)\n value = output.code }";
|
|
|
|
fn command_sources(port: &str, adapter: &str, app: &str) -> SourceMap {
|
|
sources(port, adapter, &[("notification/mail/app.dever", app)])
|
|
}
|
|
|
|
#[test]
|
|
fn command_contract_selects_only_its_bytes_policy_and_both_typed_backends() {
|
|
use dever_core::wire::Policy;
|
|
|
|
let mut source = command_sources(
|
|
COMMAND_PORT,
|
|
"external command \"bin/tool\" {}",
|
|
COMMAND_APP,
|
|
);
|
|
source.add("notification/mail/api.dever", "cmd read = app.read");
|
|
let program = checked(&source);
|
|
assert!(program.has_external_adapters());
|
|
assert!(program.external_worker_contracts().is_empty());
|
|
assert!(program.external_worker_entries().is_empty());
|
|
assert!(program.external_lib_requests().is_empty());
|
|
let contracts = program.external_command_contracts();
|
|
assert_eq!(contracts.len(), 1);
|
|
let contract = &contracts[0];
|
|
assert_eq!(contract.ecosystem, "command");
|
|
assert_eq!(contract.operations, ["execute"]);
|
|
assert_eq!(contract.entry, "module/notification/mail/bin/tool");
|
|
assert_eq!(
|
|
contract.inputs["execute"]["fields"]["stdin"]["value"],
|
|
serde_json::json!({"type":"bytes","encoding":"base64"})
|
|
);
|
|
assert_eq!(
|
|
contract.outputs["execute"]["fields"]["output"]["name"],
|
|
"dever.process.Output"
|
|
);
|
|
assert_eq!(
|
|
contract.errors.keys().collect::<Vec<_>>(),
|
|
[&"dever.process.Error.Failed".to_owned()]
|
|
);
|
|
assert!(
|
|
program
|
|
.wire_schema("dever.process.Output", Policy::Command)
|
|
.is_ok()
|
|
);
|
|
for policy in [
|
|
Policy::Output,
|
|
Policy::ApiInput,
|
|
Policy::SettingInput,
|
|
Policy::Job,
|
|
] {
|
|
assert!(
|
|
program
|
|
.wire_schema("dever.process.Output", policy)
|
|
.unwrap_err()
|
|
.contains("Bytes")
|
|
);
|
|
}
|
|
let native = dever_core::native::emit(&program, &source, "notification.mail.read").unwrap();
|
|
assert!(native.contains("node.bytes()"));
|
|
assert!(native.contains("writer.bytes(value)"));
|
|
let llvm = dever_core::llvm::emit_application(&program, &source).unwrap();
|
|
assert!(llvm.contains("call i32 @dever_rt_v1_wire_node_bytes("));
|
|
assert!(llvm.contains("call i32 @dever_rt_v1_wire_encoder_bytes("));
|
|
|
|
checked(&command_sources(
|
|
&COMMAND_PORT
|
|
.replace(", stdin: Bytes?", "")
|
|
.replace("execute", "convert"),
|
|
"external command \"bin/tool\" { allow file }",
|
|
"read() (value: Int) { output = port.convert([])\n value = output.code }",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn command_contract_rejects_signature_drift_worker_options_and_paths() {
|
|
for (port, message) in [
|
|
(
|
|
COMMAND_PORT.replace("args:", "arguments:"),
|
|
"inputs must be",
|
|
),
|
|
(COMMAND_PORT.replace("List<Text>", "Text"), "inputs must be"),
|
|
(COMMAND_PORT.replace("stdin:", "input:"), "inputs must be"),
|
|
(COMMAND_PORT.replace("Bytes?", "Bytes"), "inputs must be"),
|
|
(
|
|
COMMAND_PORT.replace("Bytes?", "Bytes?, extra: Int"),
|
|
"inputs must be",
|
|
),
|
|
(
|
|
COMMAND_PORT.replace("output:", "result:"),
|
|
"must return output",
|
|
),
|
|
(
|
|
COMMAND_PORT.replace("dever.process.Output", "Int"),
|
|
"must return output",
|
|
),
|
|
(
|
|
COMMAND_PORT.replace("dever.process.Error", "dever.process.ArgumentsResult"),
|
|
"must declare fails",
|
|
),
|
|
(
|
|
format!(
|
|
"{COMMAND_PORT}\n{}",
|
|
COMMAND_PORT.replace("execute", "convert")
|
|
),
|
|
"exactly one Port operation",
|
|
),
|
|
] {
|
|
rejected(
|
|
&command_sources(
|
|
&port,
|
|
"external command \"bin/tool\" {}",
|
|
"read() (value: Int) { value = 0 }",
|
|
),
|
|
message,
|
|
);
|
|
}
|
|
for (adapter, message) in [
|
|
(
|
|
"setting { value: Int }\nexternal command \"bin/tool\" {}",
|
|
"does not accept setting or lib",
|
|
),
|
|
(
|
|
"external command \"bin/tool\" { lib \"pip:example@1.0\" }",
|
|
"does not accept setting or lib",
|
|
),
|
|
("external command \"../tool\" {}", "portable relative path"),
|
|
("external command \"/tool\" {}", "portable relative path"),
|
|
(
|
|
"external command \"bin/\\u{0}tool\" {}",
|
|
"portable relative path",
|
|
),
|
|
(
|
|
"external command \"bin/tool\" { allow unknown }",
|
|
"unknown external capability",
|
|
),
|
|
] {
|
|
rejected(
|
|
&command_sources(COMMAND_PORT, adapter, COMMAND_APP),
|
|
message,
|
|
);
|
|
}
|
|
rejected(
|
|
&command_sources(COMMAND_PORT, "external exec \"bin/tool\" {}", COMMAND_APP),
|
|
"unsupported wire type Bytes",
|
|
);
|
|
let mut api = command_sources(
|
|
COMMAND_PORT,
|
|
"external command \"bin/tool\" {}",
|
|
"read() (output: dever.process.Output) { output = port.execute([], null) }",
|
|
);
|
|
api.add("notification/mail/api.dever", "cmd read = app.read");
|
|
rejected(&api, "Bytes");
|
|
}
|
|
|
|
#[test]
|
|
fn command_adapter_formats_plain_and_markdown_sources_idempotently() {
|
|
let adapter = "external command \"bin/tool\"{allow file\nallow process}";
|
|
let source = command_sources(COMMAND_PORT, adapter, COMMAND_APP);
|
|
checked(&source);
|
|
let plain = source
|
|
.files()
|
|
.iter()
|
|
.find(|file| file.path().ends_with("adapter.dever"))
|
|
.unwrap();
|
|
let formatted = dever_core::format::format(plain).unwrap();
|
|
assert_eq!(
|
|
formatted,
|
|
"external command \"bin/tool\" {\n allow file\n allow process\n}\n"
|
|
);
|
|
checked(&command_sources(COMMAND_PORT, &formatted, COMMAND_APP));
|
|
|
|
let markdown = format!(
|
|
"# 命令适配器\n\n执行普通二进制。\n\n- 包:`notification.mail.adapter`\n- 公开类型:无\n- 公开方法:无\n- 使用:无\n\n## 外部程序\n\n传递参数和标准输入。\n\n- 声明:`external command bin/tool`\n\n```dever\n{adapter}\n```\n"
|
|
);
|
|
let mut source = SourceMap::default();
|
|
source.add("notification/mail/app.dever", COMMAND_APP);
|
|
source.add("notification/mail/port.dever", COMMAND_PORT);
|
|
let id = source.add("notification/mail/adapter.dever.md", &markdown);
|
|
checked(&source);
|
|
let formatted = dever_core::format::format(source.get(id)).unwrap();
|
|
assert!(formatted.contains("传递参数和标准输入。"));
|
|
let mut round_trip = SourceMap::default();
|
|
let id = round_trip.add("notification/mail/adapter.dever.md", formatted.clone());
|
|
assert_eq!(
|
|
dever_core::format::format(round_trip.get(id)).unwrap(),
|
|
formatted
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_effects_and_test_fakes_reuse_the_port_boundary() {
|
|
rejected(
|
|
&command_sources(
|
|
COMMAND_PORT,
|
|
"external command \"bin/tool\" {}",
|
|
&COMMAND_APP.replace("(value: Int)", "(value: Int) pure"),
|
|
),
|
|
"pure",
|
|
);
|
|
let root = TemporaryDirectory::new();
|
|
for (path, text) in [
|
|
("module/notification/mail/app.dever", COMMAND_APP),
|
|
("module/notification/mail/port.dever", COMMAND_PORT),
|
|
(
|
|
"module/notification/mail/adapter.dever",
|
|
"external command \"bin/missing-program\" { allow process }",
|
|
),
|
|
(
|
|
"test/notification/mail/first.dever",
|
|
"first() () { assert_eq(app.read(), 23) }\nport.execute(args: List<Text>, stdin: Bytes?) (output: dever.process.Output) { output = dever.process.Output { code = 23\n stdout = dever.bytes.from_text(\"fake\")\n stderr = dever.bytes.from_text(\"\") } }",
|
|
),
|
|
] {
|
|
let path = root.path().join(path);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(path, text).unwrap();
|
|
}
|
|
let source =
|
|
SourceMap::load_project(&root.path().join("module"), &root.path().join("test")).unwrap();
|
|
let program = checked(&source);
|
|
let llvm = dever_core::llvm::emit_test_suite(&program, &source).unwrap();
|
|
assert!(!llvm.contains("@dever_rt_v1_external_start"));
|
|
assert!(!llvm.contains("bin/missing-program"));
|
|
}
|
|
|
|
#[cfg(feature = "component")]
|
|
#[test]
|
|
fn command_bytes_wire_is_lossless_strict_and_bounded() {
|
|
use dever_runtime::{bytes::Bytes, wire};
|
|
for (bytes, json) in [(vec![], "\"\""), (vec![0, 255, 128, 10], "\"AP+ACg==\"")] {
|
|
let bytes = Bytes::new(bytes);
|
|
let mut writer = wire::Encoder::default();
|
|
writer.bytes(&bytes).unwrap();
|
|
let encoded = writer.finish().unwrap();
|
|
assert_eq!(encoded.as_str(), json);
|
|
assert_eq!(wire::parse(json).unwrap().bytes().unwrap(), bytes);
|
|
}
|
|
for json in [
|
|
"null",
|
|
"[0,255]",
|
|
"\"_w==\"",
|
|
"\"/w\"",
|
|
"\"/x==\"",
|
|
"\" /w==\"",
|
|
] {
|
|
assert!(wire::parse(json).unwrap().bytes().is_err(), "{json}");
|
|
}
|
|
let oversized = Bytes::new(vec![0; wire::MAX_BYTES * 3 / 4]);
|
|
assert!(wire::Encoder::default().bytes(&oversized).is_err());
|
|
}
|
|
|
|
fn sources(port: &str, adapter: &str, extra: &[(&str, &str)]) -> SourceMap {
|
|
let mut sources = SourceMap::default();
|
|
sources.add("notification/mail/port.dever", port);
|
|
sources.add("notification/mail/adapter.dever", adapter);
|
|
if !extra
|
|
.iter()
|
|
.any(|(path, _)| *path == "notification/mail/app.dever")
|
|
{
|
|
sources.add("notification/mail/app.dever", APP);
|
|
}
|
|
for (path, text) in extra {
|
|
sources.add(*path, *text);
|
|
}
|
|
sources
|
|
}
|
|
|
|
fn checked(sources: &SourceMap) -> dever_core::hir::Program {
|
|
dever_core::check(sources).unwrap_or_else(|errors| {
|
|
panic!(
|
|
"{}",
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(sources))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
)
|
|
})
|
|
}
|
|
|
|
fn generated_external_schema(port: &str, app: &str, adapter: &str) -> String {
|
|
let source = sources(
|
|
port,
|
|
adapter,
|
|
&[
|
|
("notification/mail/app.dever", app),
|
|
(
|
|
"main.dever",
|
|
"main() (value: Int) { value = notification.mail.read() }",
|
|
),
|
|
],
|
|
);
|
|
let program = checked(&source);
|
|
let generated = dever_core::native::emit(&program, &source, "main.main").unwrap();
|
|
let definition = generated
|
|
.split("dever_runtime::component::Definition {")
|
|
.nth(1)
|
|
.expect("external component definition");
|
|
let schema = definition
|
|
.split("schema: \"")
|
|
.nth(1)
|
|
.expect("external schema literal");
|
|
schema
|
|
.split('"')
|
|
.next()
|
|
.expect("external schema value")
|
|
.to_owned()
|
|
}
|
|
|
|
fn rejected(sources: &SourceMap, message: &str) {
|
|
let errors = dever_core::check(sources).expect_err("must reject invalid Port boundary");
|
|
assert!(
|
|
errors.iter().any(|error| error.message.contains(message)),
|
|
"{errors:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn checks_bodyless_contract_implementation_and_setting_schema() {
|
|
checked(&sources(PORT, ADAPTER, &[]));
|
|
checked(&sources(
|
|
PORT,
|
|
"setting { value: Int }\nport.read() (value: Int) { value = setting.value }",
|
|
&[],
|
|
));
|
|
rejected(
|
|
&sources(
|
|
&PORT.replace("fails app.ReadResult", "{ value = 1 }"),
|
|
ADAPTER,
|
|
&[],
|
|
),
|
|
"bodyless",
|
|
);
|
|
rejected(
|
|
&sources(PORT, "port.read() (value: Text) { value = \"wrong\" }", &[]),
|
|
"preserve Port",
|
|
);
|
|
rejected(
|
|
&sources(
|
|
PORT,
|
|
"type Other { error Broken(message: Text) }\nport.read() (value: Int) { fail(Other.Broken(\"failed\")) }",
|
|
&[],
|
|
),
|
|
"failure set exceeds",
|
|
);
|
|
rejected(
|
|
&sources(
|
|
PORT,
|
|
"setting { value: Int }\nport.read() (value: Int) {\n setting.value = 1\n value = 7\n}",
|
|
&[],
|
|
),
|
|
"read-only",
|
|
);
|
|
checked(&sources(
|
|
PORT,
|
|
ADAPTER,
|
|
&[(
|
|
"notification/mail/app.dever",
|
|
&format!("{APP}\nsetting() (value: Int) {{ value = 1 }}"),
|
|
)],
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn port_contract_types_include_nested_app_dtos() {
|
|
let port = "type Message { recipient: app.Recipient }\nread(message: Message) (value: Int) fails app.ReadResult";
|
|
let adapter = "port.read(message: port.Message) (value: Int) { value = message.recipient.id }";
|
|
let app = "type Recipient { id: Int }\ntype Unrelated { value: Int }\ntype ReadResult { error Unavailable(message: Text) }\nread() (value: Int) { value = port.read(port.Message { recipient = Recipient { id = 1 } }) }";
|
|
let extra = [("notification/mail/app.dever", app)];
|
|
checked(&sources(port, adapter, &extra));
|
|
rejected(
|
|
&sources(
|
|
port,
|
|
&format!("{adapter}\ntype Private {{ unrelated: app.Unrelated }}"),
|
|
&extra,
|
|
),
|
|
"outside the current domain boundary",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_role_bypasses_and_private_port_failure_exports() {
|
|
rejected(
|
|
&sources(
|
|
"type Error { error Failed(message: Text) }\nread() (value: Int) fails Error",
|
|
ADAPTER,
|
|
&[],
|
|
),
|
|
"public App error choice",
|
|
);
|
|
rejected(
|
|
&sources(
|
|
PORT,
|
|
"read() (value: Int) { value = 1 }\nport.read() (value: Int) { value = read() }",
|
|
&[(
|
|
"notification/mail/app.dever",
|
|
"type ReadResult { error Unavailable(message: Text) }\nread() (value: Int) { value = adapter.read() }",
|
|
)],
|
|
),
|
|
"not callable",
|
|
);
|
|
rejected(
|
|
&sources(
|
|
PORT,
|
|
ADAPTER,
|
|
&[(
|
|
"notification/mail/domain.dever",
|
|
"read() (value: Int) { value = port.read() }",
|
|
)],
|
|
),
|
|
"not callable",
|
|
);
|
|
rejected(
|
|
&sources(PORT, "port.read() (value: Int) { value = app.read() }", &[]),
|
|
"not callable",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn formats_port_and_qualified_adapter_without_changing_contracts() {
|
|
for (path, text) in [
|
|
("notification/mail/port.dever", PORT),
|
|
(
|
|
"notification/mail/adapter.dever",
|
|
"setting { value: Int }\nport.read() (value: Int) { value = setting.value }",
|
|
),
|
|
] {
|
|
let mut sources = SourceMap::default();
|
|
let id = sources.add(path, text);
|
|
let formatted = dever_core::format::format(sources.get(id)).unwrap();
|
|
let mut formatted_sources = SourceMap::default();
|
|
let id = formatted_sources.add(path, &formatted);
|
|
assert_eq!(
|
|
formatted,
|
|
dever_core::format::format(formatted_sources.get(id)).unwrap()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn checks_and_formats_external_exec_adapter_contract() {
|
|
let adapter = "external exec \"worker/mail\" {\n allow network\n allow gpu\n}";
|
|
let source = sources(PORT, adapter, &[]);
|
|
checked(&source);
|
|
let id = source
|
|
.files()
|
|
.iter()
|
|
.find(|file| file.path().to_string_lossy().contains("adapter"))
|
|
.expect("Adapter source")
|
|
.id();
|
|
let formatted = dever_core::format::format(source.get(id)).unwrap();
|
|
assert_eq!(
|
|
formatted,
|
|
"external exec \"worker/mail\" {\n allow network\n allow gpu\n}\n"
|
|
);
|
|
let mut round_trip = SourceMap::default();
|
|
round_trip.add("notification/mail/adapter.dever", &formatted);
|
|
round_trip.add("notification/mail/app.dever", APP);
|
|
round_trip.add("notification/mail/port.dever", PORT);
|
|
checked(&round_trip);
|
|
|
|
checked(&sources(
|
|
PORT,
|
|
"setting {\n endpoint: Text\n token: Secret\n}\nexternal exec \"worker/mail\" { allow network }",
|
|
&[],
|
|
));
|
|
|
|
rejected(
|
|
&sources(PORT, "external exec \"../worker\" {}", &[]),
|
|
"portable relative path",
|
|
);
|
|
rejected(
|
|
&sources(PORT, "external exec \"worker\" { allow unknown }", &[]),
|
|
"unknown external capability",
|
|
);
|
|
rejected(
|
|
&sources(
|
|
PORT,
|
|
"external exec \"worker\" {\n allow file\n allow file\n}",
|
|
&[],
|
|
),
|
|
"duplicate external capability",
|
|
);
|
|
rejected(
|
|
&sources(
|
|
PORT,
|
|
"external exec \"worker\" {}\nport.read() (value: Int) { value = 1 }",
|
|
&[],
|
|
),
|
|
"may contain only",
|
|
);
|
|
|
|
let mut missing = SourceMap::default();
|
|
missing.add(
|
|
"notification/mail/adapter.dever",
|
|
"external exec \"worker\" {}",
|
|
);
|
|
rejected(&missing, "requires one Port identity");
|
|
|
|
let mut ambiguous = SourceMap::default();
|
|
ambiguous.add("notification/mail/app.dever", APP);
|
|
ambiguous.add("notification/mail/port/primary.dever", PORT);
|
|
ambiguous.add(
|
|
"notification/mail/port/secondary.dever",
|
|
"send() () fails app.ReadResult",
|
|
);
|
|
ambiguous.add(
|
|
"notification/mail/adapter.dever",
|
|
"external exec \"worker\" {}",
|
|
);
|
|
rejected(&ambiguous, "multiple Port identities");
|
|
|
|
let mut wrong_role = SourceMap::default();
|
|
let id = wrong_role.add("notification/mail/app.dever", "external exec \"worker\" {}");
|
|
assert!(
|
|
dever_core::parse(wrong_role.get(id)).is_err(),
|
|
"external must stay contextual to Adapter sources"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn external_adapter_exposes_exact_lib_requests_to_the_checked_program() {
|
|
let source = sources(
|
|
PORT,
|
|
"external exec \"worker/mail\" {\n lib \"pip:fixture@1.0.0\"\n lib \"npm:fixture@1.0.0\"\n allow network\n}",
|
|
&[(
|
|
"main.dever",
|
|
"main() (value: Int) { value = notification.mail.read() }",
|
|
)],
|
|
);
|
|
let program = checked(&source);
|
|
assert_eq!(
|
|
program.external_lib_requests(),
|
|
vec![
|
|
"npm:fixture@1.0.0".to_owned(),
|
|
"pip:fixture@1.0.0".to_owned(),
|
|
]
|
|
);
|
|
let adapter = source
|
|
.files()
|
|
.iter()
|
|
.find(|file| file.path().to_string_lossy().contains("adapter"))
|
|
.unwrap();
|
|
let formatted = dever_core::format::format(source.get(adapter.id())).unwrap();
|
|
assert!(formatted.contains("lib \"pip:fixture@1.0.0\""));
|
|
assert!(formatted.contains("lib \"npm:fixture@1.0.0\""));
|
|
}
|
|
|
|
#[test]
|
|
fn external_schema_binds_setting_and_top_level_wire_names() {
|
|
let adapter = |field: &str| {
|
|
format!(
|
|
"setting {{ endpoint: {field} }}\nexternal exec \"worker/mail\" {{ allow network }}"
|
|
)
|
|
};
|
|
let base = generated_external_schema(PORT, APP, &adapter("Text"));
|
|
let changed_setting = generated_external_schema(PORT, APP, &adapter("Int"));
|
|
let renamed_output = generated_external_schema(
|
|
"read() (count: Int) fails app.ReadResult\n",
|
|
"type ReadResult {\n Read(value: Int)\n error Unavailable(message: Text)\n}\nread() (count: Int) { count = port.read() }\n",
|
|
&adapter("Text"),
|
|
);
|
|
assert_ne!(
|
|
base, changed_setting,
|
|
"setting schema must participate in the protocol identity"
|
|
);
|
|
assert_ne!(
|
|
base, renamed_output,
|
|
"top-level wire field names must participate in the protocol identity"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn managed_external_adapters_expose_checked_typed_worker_contracts() {
|
|
let port = "type Address { city: Text }\ntype Message {\n recipient: Address\n tags: List<Text>\n}\nread(message: Message) (value: Int?) fails app.ReadResult";
|
|
let app = "type ReadResult { error Unavailable(message: Text, retry_after: Int?) }";
|
|
for (ecosystem, entry) in [
|
|
("pip", "python/main.py"),
|
|
("npm", "javascript/main.mjs"),
|
|
("go", "go/main.go"),
|
|
] {
|
|
let adapter = format!(
|
|
"setting {{ token: Secret }}\nexternal {ecosystem} \"{entry}\" {{\n lib \"sample@1.2.3\"\n allow network\n}}"
|
|
);
|
|
let source = sources(port, &adapter, &[("notification/mail/app.dever", app)]);
|
|
let contracts = checked(&source).external_worker_contracts();
|
|
let [contract] = contracts.as_slice() else {
|
|
panic!("one checked Worker")
|
|
};
|
|
assert_eq!(contract.ecosystem, ecosystem);
|
|
assert_eq!(contract.libs, [format!("{ecosystem}:sample@1.2.3")]);
|
|
assert_eq!(
|
|
contract.setting.as_ref().unwrap()["fields"]["token"]["type"],
|
|
"secret"
|
|
);
|
|
assert_eq!(
|
|
contract.inputs["read"]["fields"]["message"]["fields"]["recipient"]["fields"]["city"]["type"],
|
|
"text"
|
|
);
|
|
assert_eq!(
|
|
contract.inputs["read"]["fields"]["message"]["fields"]["tags"]["type"],
|
|
"list"
|
|
);
|
|
assert_eq!(
|
|
contract.outputs["read"]["fields"]["value"]["type"],
|
|
"nullable"
|
|
);
|
|
let errors = contract.errors.iter().collect::<Vec<_>>();
|
|
let [(identity, error)] = errors.as_slice() else {
|
|
panic!("one declared error")
|
|
};
|
|
assert!(identity.ends_with("ReadResult.Unavailable"));
|
|
assert_eq!(error["fields"]["retry_after"]["type"], "nullable");
|
|
assert_eq!(contract.sdk_manifest()["schema"], contract.schema);
|
|
let adapter_source = source
|
|
.files()
|
|
.iter()
|
|
.find(|file| file.path().to_string_lossy().contains("adapter"))
|
|
.unwrap();
|
|
let formatted = dever_core::format::format(source.get(adapter_source.id())).unwrap();
|
|
assert!(formatted.contains(&format!("external {ecosystem} \"{entry}\"")));
|
|
}
|
|
rejected(
|
|
&sources(
|
|
PORT,
|
|
"external pip \"python/main.py\" { lib \"npm:other@1.0\" }",
|
|
&[],
|
|
),
|
|
"exact name@version",
|
|
);
|
|
|
|
let source = sources(
|
|
PORT,
|
|
"external pip \"python/main.py\" { lib \"sample@1.2.3\" }",
|
|
&[(
|
|
"main.dever",
|
|
"main() (value: Int) { value = notification.mail.read() }",
|
|
)],
|
|
);
|
|
let generated = dever_core::native::emit(&checked(&source), &source, "main.main").unwrap();
|
|
assert!(generated.contains("ecosystem: \"pip\""));
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_contract_accepts_external_exec_declaration() {
|
|
let mut source = SourceMap::default();
|
|
source.add("notification/mail/app.dever", APP);
|
|
source.add("notification/mail/port.dever", PORT);
|
|
let adapter = "# 外部邮件适配器\n\n通过受管 Worker 实现邮件边界。\n\n- 包:`notification.mail.adapter`\n- 公开类型:无\n- 公开方法:无\n- 使用:无\n\n## Worker\n\n声明外部 Worker。\n\n- 声明:`external exec worker/mail`\n\n```dever\nexternal exec \"worker/mail\" {\n allow network\n}\n```\n";
|
|
source.add("notification/mail/adapter.dever.md", adapter);
|
|
checked(&source);
|
|
|
|
let mut invalid = SourceMap::default();
|
|
invalid.add("notification/mail/app.dever", APP);
|
|
invalid.add("notification/mail/port.dever", PORT);
|
|
invalid.add(
|
|
"notification/mail/adapter.dever.md",
|
|
adapter.replace("external exec worker/mail`", "external exec worker/other`"),
|
|
);
|
|
rejected(&invalid, "documented Model declaration");
|
|
}
|
|
|
|
#[test]
|
|
fn external_exec_adapter_emits_dispatch_and_shutdown_cleanup() {
|
|
let source = sources(
|
|
PORT,
|
|
"external exec \"worker/mail\" { allow network }",
|
|
&[(
|
|
"main.dever",
|
|
"main() (value: Int) { value = notification.mail.read() }",
|
|
)],
|
|
);
|
|
let program = checked(&source);
|
|
let generated = dever_core::native::emit(&program, &source, "main.main").unwrap();
|
|
assert!(generated.contains("dever_runtime::component::call("));
|
|
assert!(generated.contains("dever_runtime::component::shutdown().await"));
|
|
assert!(generated.contains("match (application, cleanup)"));
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_contract_includes_declared_failures_and_qualified_implementation() {
|
|
let mut source = SourceMap::default();
|
|
source.add("notification/mail/app.dever", APP);
|
|
let port = "# 邮件边界\n\n读取邮件计数。\n\n- 包:`notification.mail.port`\n- 公开类型:无\n- 公开方法:无\n- 使用:无\n\n## 读取计数\n\n声明读取合同。\n\n- 函数:`read`\n- 输入:无\n- 输出:\n - `value: Int`:计数。\n- 允许失败:`app.ReadResult`\n\n```dever\nread() (value: Int) fails app.ReadResult\n```\n";
|
|
let adapter = "# 邮件实现\n\n提供固定计数。\n\n- 包:`notification.mail.adapter`\n- 公开类型:无\n- 公开方法:无\n- 使用:无\n\n## 读取计数\n\n实现读取合同。\n\n- 函数:`port.read`\n- 输入:无\n- 输出:\n - `value: Int`:计数。\n\n```dever\nport.read() (value: Int) { value = 7 }\n```\n";
|
|
source.add("notification/mail/port.dever.md", port);
|
|
source.add("notification/mail/adapter.dever.md", adapter);
|
|
checked(&source);
|
|
let mut invalid = SourceMap::default();
|
|
invalid.add("notification/mail/app.dever", APP);
|
|
invalid.add(
|
|
"notification/mail/port.dever.md",
|
|
port.replace("允许失败:`app.ReadResult`", "允许失败:`app.Other`"),
|
|
);
|
|
invalid.add("notification/mail/adapter.dever", ADAPTER);
|
|
rejected(&invalid, "documented Port failure choice");
|
|
}
|
|
|
|
#[test]
|
|
fn effects_union_and_direct_io_boundaries_are_conservative() {
|
|
let mut source = SourceMap::default();
|
|
source.add("notification/mail/app.dever", "pause() () { port.pause() }");
|
|
source.add(
|
|
"notification/mail/port.dever",
|
|
"pause() () fails dever.time.SleepResult",
|
|
);
|
|
source.add(
|
|
"notification/mail/adapter/blocking.dever",
|
|
"port.pause() () { dever.time.sleep(1) }",
|
|
);
|
|
source.add(
|
|
"notification/mail/adapter/suspending.dever",
|
|
"port.pause() () { dever.task.sleep(1) }",
|
|
);
|
|
rejected(&source, "blocking");
|
|
|
|
let mut source = SourceMap::default();
|
|
source.add(
|
|
"notification/mail/app.dever",
|
|
"open() (file: dever.system.File) { file = dever.io.open(\"not-opened-by-check\") }",
|
|
);
|
|
rejected(&source, "I/O must pass through a Port");
|
|
|
|
let source = sources(
|
|
PORT,
|
|
"port.read() (value: Int) { fail(app.ReadResult.Unavailable(\"offline\")) }",
|
|
&[],
|
|
);
|
|
let program = checked(&source);
|
|
assert!(program.api_snapshot().contains("Unavailable"));
|
|
let source = sources(
|
|
PORT,
|
|
ADAPTER,
|
|
&[(
|
|
"notification/mail/app.dever",
|
|
"type ReadResult {\n Read(value: Int)\n error Unavailable(message: Text)\n}\ncapture() (response: ReadResult) { response = result(port.read()) }",
|
|
)],
|
|
);
|
|
checked(&source);
|
|
}
|
|
|
|
#[test]
|
|
fn native_selection_decodes_only_selected_setting_and_rejects_invalid_config() {
|
|
let mut source = SourceMap::default();
|
|
source.add(
|
|
"main.dever",
|
|
"main() (value: Int) { value = notification.mail.read() }",
|
|
);
|
|
source.add("notification/mail/app.dever", APP);
|
|
source.add("notification/mail/port.dever", PORT);
|
|
source.add("notification/mail/adapter/fixed.dever", ADAPTER);
|
|
source.add("notification/mail/adapter/configured.dever", "setting {\n value: Int\n token: Secret\n}\nport.read() (value: Int) { value = setting.value }");
|
|
let program = checked(&source);
|
|
let native = dever_core::native::compile(
|
|
&program,
|
|
&source,
|
|
"main.main",
|
|
std::ffi::OsStr::new("rustc"),
|
|
)
|
|
.unwrap();
|
|
let root = TemporaryDirectory::new();
|
|
let executable = root.path().join("program");
|
|
native.save(&executable).unwrap();
|
|
fs::create_dir(root.path().join("config")).unwrap();
|
|
let config = root.path().join("config/setting.json");
|
|
for (text, expected) in [
|
|
(
|
|
r#"{"adapter":{"notification.mail":{"use":"fixed"}}}"#,
|
|
Some("value = 7\n"),
|
|
),
|
|
(
|
|
r#"{"adapter":{"notification.mail":{"use":"configured","setting":{"value":19,"token":"never-print-this-secret"}}}}"#,
|
|
Some("value = 19\n"),
|
|
),
|
|
(
|
|
r#"{"adapter":{"notification.mail":{"use":"unknown"}}}"#,
|
|
None,
|
|
),
|
|
(r#"{"adapter":{"notification.mail":{}}}"#, None),
|
|
(
|
|
r#"{"adapter":{"notification.mail":{"use":"configured"}}}"#,
|
|
None,
|
|
),
|
|
(
|
|
r#"{"adapter":{"notification.mail":{"use":"configured","setting":{"value":19,"extra":1}}}}"#,
|
|
None,
|
|
),
|
|
(
|
|
r#"{"adapter":{"notification.mail":{"use":"configured","setting":{"value":"never-print-this-secret"}}}}"#,
|
|
None,
|
|
),
|
|
(
|
|
r#"{"adapter":{"notification.mail":{"use":"fixed","use":"configured"}}}"#,
|
|
None,
|
|
),
|
|
(r#"{"adapter":{"unknown.port":{"use":"fixed"}}}"#, None),
|
|
(r#"{"adapter":null}"#, None),
|
|
] {
|
|
fs::write(&config, text).unwrap();
|
|
let result = Command::new(&executable).output().unwrap();
|
|
assert_eq!(
|
|
result.status.success(),
|
|
expected.is_some(),
|
|
"{}",
|
|
String::from_utf8_lossy(&result.stderr)
|
|
);
|
|
if let Some(expected) = expected {
|
|
assert_eq!(String::from_utf8_lossy(&result.stdout), expected);
|
|
}
|
|
assert!(!String::from_utf8_lossy(&result.stderr).contains("never-print-this-secret"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn single_adapter_executes_without_settings() {
|
|
let source = sources(
|
|
PORT,
|
|
ADAPTER,
|
|
&[(
|
|
"main.dever",
|
|
"main() (value: Int) { value = notification.mail.read() }",
|
|
)],
|
|
);
|
|
let program = checked(&source);
|
|
let native = dever_core::native::compile(
|
|
&program,
|
|
&source,
|
|
"main.main",
|
|
std::ffi::OsStr::new("rustc"),
|
|
)
|
|
.unwrap();
|
|
let root = TemporaryDirectory::new();
|
|
let executable = root.path().join("program");
|
|
native.save(&executable).unwrap();
|
|
let result = Command::new(&executable).output().unwrap();
|
|
assert!(
|
|
result.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&result.stderr)
|
|
);
|
|
assert_eq!(String::from_utf8_lossy(&result.stdout), "value = 7\n");
|
|
fs::create_dir(root.path().join("config")).unwrap();
|
|
fs::write(
|
|
root.path().join("config/setting.json"),
|
|
r#"{"adapter":null}"#,
|
|
)
|
|
.unwrap();
|
|
let result = Command::new(&executable).output().unwrap();
|
|
assert!(
|
|
!result.status.success(),
|
|
"explicit null must not become an omitted binding"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_suite_uses_each_case_fake_without_production_settings() {
|
|
let root = TemporaryDirectory::new();
|
|
for (path, text) in [
|
|
("module/main.dever", "main() () {}"),
|
|
("module/notification/mail/app.dever", APP),
|
|
("module/notification/mail/port.dever", PORT),
|
|
(
|
|
"module/notification/mail/adapter.dever",
|
|
"setting { value: Int }\nport.read() (value: Int) { value = setting.value }",
|
|
),
|
|
(
|
|
"test/notification/mail/first.dever",
|
|
"first() () { assert_eq(app.read(), 11) }\nport.read() (value: Int) { value = 11 }",
|
|
),
|
|
(
|
|
"test/notification/mail/second.dever",
|
|
"second() () { assert_eq(app.read(), 23) }\nport.read() (value: Int) { value = 23 }",
|
|
),
|
|
(
|
|
"config/setting.json",
|
|
"invalid production setting must not be read",
|
|
),
|
|
] {
|
|
let path = root.path().join(path);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(path, text).unwrap();
|
|
}
|
|
let source =
|
|
SourceMap::load_project(&root.path().join("module"), &root.path().join("test")).unwrap();
|
|
let program = checked(&source);
|
|
let native =
|
|
dever_core::native::compile_test_suite(&program, &source, std::ffi::OsStr::new("rustc"))
|
|
.unwrap();
|
|
for index in 0..2 {
|
|
let result = Command::new(native.executable())
|
|
.arg(index.to_string())
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
result.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&result.stderr)
|
|
);
|
|
}
|
|
fs::write(
|
|
root.path().join("test/notification/mail/first.dever"),
|
|
"first() () { assert_eq(app.read(), 11) }",
|
|
)
|
|
.unwrap();
|
|
let source =
|
|
SourceMap::load_project(&root.path().join("module"), &root.path().join("test")).unwrap();
|
|
rejected(&source, "requires a fake");
|
|
}
|
|
|
|
#[test]
|
|
fn test_suite_keeps_blocking_and_suspending_fakes_case_local() {
|
|
let root = TemporaryDirectory::new();
|
|
for (path, text) in [
|
|
("module/main.dever", "main() () {}"),
|
|
(
|
|
"module/notification/mail/app.dever",
|
|
"pause() () { port.pause() }",
|
|
),
|
|
(
|
|
"module/notification/mail/port.dever",
|
|
"pause() () fails dever.time.SleepResult",
|
|
),
|
|
(
|
|
"module/notification/mail/adapter.dever",
|
|
"port.pause() () {}",
|
|
),
|
|
(
|
|
"test/notification/mail/first.dever",
|
|
"first() () {\n dever.time.sleep(1)\n app.pause()\n}\nport.pause() () { dever.time.sleep(1) }",
|
|
),
|
|
(
|
|
"test/notification/mail/second.dever",
|
|
"second() () { app.pause() }\nport.pause() () { dever.task.sleep(1) }",
|
|
),
|
|
] {
|
|
let path = root.path().join(path);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(path, text).unwrap();
|
|
}
|
|
let source =
|
|
SourceMap::load_project(&root.path().join("module"), &root.path().join("test")).unwrap();
|
|
let program = checked(&source);
|
|
let native =
|
|
dever_core::native::compile_test_suite(&program, &source, std::ffi::OsStr::new("rustc"))
|
|
.unwrap();
|
|
for index in 0..2 {
|
|
let result = Command::new(native.executable())
|
|
.arg(index.to_string())
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
result.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&result.stderr)
|
|
);
|
|
}
|
|
let second = root.path().join("test/notification/mail/second.dever");
|
|
fs::write(&second, "second() () {\n dever.time.sleep(1)\n app.pause()\n}\nport.pause() () { dever.task.sleep(1) }").unwrap();
|
|
let source =
|
|
SourceMap::load_project(&root.path().join("module"), &root.path().join("test")).unwrap();
|
|
rejected(&source, "blocking");
|
|
fs::write(&second, "type Other { error Broken(message: Text) }\nsecond() () {}\nport.pause() () { fail(Other.Broken(\"unused fake still must match\")) }").unwrap();
|
|
let source =
|
|
SourceMap::load_project(&root.path().join("module"), &root.path().join("test")).unwrap();
|
|
rejected(&source, "failure set exceeds");
|
|
fs::write(
|
|
&second,
|
|
"second() () {}\nunused(action: handler() ()) () pure { dever.time.sleep(1) }",
|
|
)
|
|
.unwrap();
|
|
let source =
|
|
SourceMap::load_project(&root.path().join("module"), &root.path().join("test")).unwrap();
|
|
rejected(&source, "pure function");
|
|
}
|