Spool native cargo build and test streams to persistent world logs

This commit is contained in:
2026-10-26 13:54:15 +00:00
parent 4be9ae41de
commit e26260d36e
3 changed files with 285 additions and 21 deletions
+15 -1
View File
@@ -448,9 +448,23 @@ builds `/plugins/notes/source/`:
3. It builds the release `.wasm` for `wasm32-wasip2`. The tests and the build 3. It builds the release `.wasm` for `wasm32-wasip2`. The tests and the build
share the compiled dependencies. share the compiled dependencies.
4. It returns `{"wasm": "/state/cargo/builds/<n>/notes.wasm", "tests": 4. It returns `{"wasm": "/state/cargo/builds/<n>/notes.wasm", "tests":
{"passed", "failed", "output"}, "warnings"}`. If a step fails, it returns {"passed", "failed", "output"}, "warnings", "logs"}`. If a step fails, it returns
an error with the compiler or test output. an error with the compiler or test output.
- Before running commands, it creates `/logs/cargo/<YYYY-MM-DD>/<build-id>.stdout.log`
and `.stderr.log` (UTC date, unique build ID). Both test and release phases
append their raw bytes directly to these files, before any parsing, progress
filtering or UTF-8 conversion. This includes successful JSON build output.
`/logs` is a top-level world folder, created if missing, not under `/state`.
- Success results and error -32004 `data` include `logs`:
`{"stdout":{"path":"/logs/cargo/...stdout.log","bytes":123},
"stderr":{"path":"/logs/cargo/...stderr.log","bytes":45},"total_bytes":168}`.
Counts are raw bytes, not characters. `logs` is null only if log creation
itself fails. Preparation failures otherwise include empty log files.
Inline diagnostics may be filtered or shortened; the files are the full truth.
- Logs remain after failure, total/test timeout, and cancellation. Cancellation
need not return a result; its files remain discoverable in `/logs/cargo/`.
Pruning numeric `/state/cargo/builds/` artifact folders does not prune logs.
- Optional `timeout_secs` is an integer of at least 1, default **900** (not - Optional `timeout_secs` is an integer of at least 1, default **900** (not
null or unlimited). The wall-clock budget starts when the build acquires its null or unlimited). The wall-clock budget starts when the build acquires its
mutex, excluding queue time, and covers tests and release compilation together. mutex, excluding queue time, and covers tests and release compilation together.
+233 -20
View File
@@ -9,7 +9,7 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
}; };
use tokio::{io::AsyncReadExt, process::Command, time::Instant}; use tokio::{process::Command, time::Instant};
/// Default wall-clock budget after acquiring the build mutex (not queue time). /// Default wall-clock budget after acquiring the build mutex (not queue time).
pub const DEFAULT_TIMEOUT_SECS: u64 = 900; pub const DEFAULT_TIMEOUT_SECS: u64 = 900;
@@ -58,9 +58,78 @@ pub struct TestResults {
pub failed: u64, pub failed: u64,
pub output: String, pub output: String,
} }
/// Raw, unfiltered output shared by the test and release phases of one build.
#[derive(Clone, Debug, serde::Serialize)]
pub struct BuildLogs {
pub stdout: StreamLog,
pub stderr: StreamLog,
pub total_bytes: u64,
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct StreamLog {
pub path: String,
pub bytes: u64,
}
/// Persistent descriptors are inherited by cargo, so cancellation cannot lose
/// bytes already written by cargo or its descendants. No buffered writer is used.
struct LogFiles {
stdout: std::fs::File,
stderr: std::fs::File,
stdout_path: PathBuf,
stderr_path: PathBuf,
}
impl LogFiles {
fn create(world: &Path) -> Result<Self> {
let directory = world
.join("logs/cargo")
.join(chrono::Utc::now().format("%Y-%m-%d").to_string());
std::fs::create_dir_all(&directory).context("create cargo log directory")?;
let id = uuid::Uuid::now_v7();
let stdout_path = directory.join(format!("{id}.stdout.log"));
let stderr_path = directory.join(format!("{id}.stderr.log"));
let create = |path: &Path| {
std::fs::OpenOptions::new()
.create_new(true)
.append(true)
.open(path)
.with_context(|| format!("create {}", path.display()))
};
Ok(Self {
stdout: create(&stdout_path)?,
stderr: create(&stderr_path)?,
stdout_path,
stderr_path,
})
}
fn info(&self, world: &Path) -> Result<BuildLogs> {
let stream = |file: &std::fs::File, path: &Path| -> Result<StreamLog> {
Ok(StreamLog {
path: format!("/{}", path.strip_prefix(world)?.display()),
bytes: file.metadata()?.len(),
})
};
let stdout = stream(&self.stdout, &self.stdout_path)?;
let stderr = stream(&self.stderr, &self.stderr_path)?;
Ok(BuildLogs {
total_bytes: stdout.bytes + stderr.bytes,
stdout,
stderr,
})
}
fn read(&self, path: &Path, offset: u64) -> Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let mut file =
std::fs::File::open(path).with_context(|| format!("read {}", path.display()))?;
file.seek(SeekFrom::Start(offset))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
}
/// A successful build, usable without a running host. /// A successful build, usable without a running host.
#[derive(Debug)] #[derive(Debug)]
pub struct BuildOutput { pub struct BuildOutput {
pub logs: Option<BuildLogs>,
pub wasm: PathBuf, pub wasm: PathBuf,
pub warnings: String, pub warnings: String,
pub tests: TestResults, pub tests: TestResults,
@@ -68,6 +137,7 @@ pub struct BuildOutput {
/// A failed compiler or test phase, with its complete diagnostics. /// A failed compiler or test phase, with its complete diagnostics.
#[derive(Debug)] #[derive(Debug)]
pub struct BuildFailure { pub struct BuildFailure {
pub logs: Option<BuildLogs>,
pub reason: String, pub reason: String,
pub tests: TestResults, pub tests: TestResults,
pub output: String, pub output: String,
@@ -83,6 +153,7 @@ impl std::error::Error for BuildFailure {}
impl From<anyhow::Error> for BuildFailure { impl From<anyhow::Error> for BuildFailure {
fn from(error: anyhow::Error) -> Self { fn from(error: anyhow::Error) -> Self {
Self { Self {
logs: None,
reason: "build preparation failed".into(), reason: "build preparation failed".into(),
output: format!("{error:#}"), output: format!("{error:#}"),
tests: TestResults::default(), tests: TestResults::default(),
@@ -179,7 +250,7 @@ impl Cargo {
host.register_native(ID,Arc::new(move |_context,method,params| { host.register_native(ID,Arc::new(move |_context,method,params| {
let builder=builder.clone(); let builder=builder.clone();
Box::pin(async move { Box::pin(async move {
if method=="rpc.discover" { return Ok(json!({"openrpc":"1.2.6","info":{"title":"cargo","version":"native"},"methods":[{"name":"build","paramStructure":"by-name","description":"Copies source, runs bounded WASI tests, then builds a release component. Deadlines kill cargo and its descendants; cancellation releases the build mutex.","params":[{"name":"plugin","required":true,"schema":{"type":"string"}},{"name":"timeout_secs","required":false,"description":"Total wall-clock limit in seconds for tests and release build after acquiring the build mutex (queue time excluded). Default 900; minimum 1. Cannot be disabled.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TIMEOUT_SECS}},{"name":"test_timeout_secs","required":false,"description":"Wall-clock limit in seconds for each WASI test instance. Default 120; minimum 1. A timed-out test is named and fails; remaining tests continue.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TEST_TIMEOUT_SECS}}],"result":{"name":"build","schema":{"type":"object","required":["wasm","tests","warnings"],"properties":{"wasm":{"type":"string"},"warnings":{"type":"string"},"tests":{"type":"object","required":["passed","failed","output"],"properties":{"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"output":{"type":"string"}}}}}},"errors":[{"code":-32004,"message":"Build failed"}]}]})); } if method=="rpc.discover" { return Ok(json!({"openrpc":"1.2.6","info":{"title":"cargo","version":"native"},"methods":[{"name":"build","paramStructure":"by-name","description":"Copies source, runs bounded WASI tests, then builds a release component. Deadlines kill cargo and its descendants; cancellation releases the build mutex.","params":[{"name":"plugin","required":true,"schema":{"type":"string"}},{"name":"timeout_secs","required":false,"description":"Total wall-clock limit in seconds for tests and release build after acquiring the build mutex (queue time excluded). Default 900; minimum 1. Cannot be disabled.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TIMEOUT_SECS}},{"name":"test_timeout_secs","required":false,"description":"Wall-clock limit in seconds for each WASI test instance. Default 120; minimum 1. A timed-out test is named and fails; remaining tests continue.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TEST_TIMEOUT_SECS}}],"result":{"name":"build","schema":{"type":"object","required":["wasm","tests","warnings","logs"],"properties":{"logs":{"type":"object","required":["stdout","stderr","total_bytes"],"properties":{"stdout":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"stderr":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"total_bytes":{"type":"integer","minimum":0}}},"wasm":{"type":"string"},"warnings":{"type":"string"},"tests":{"type":"object","required":["passed","failed","output"],"properties":{"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"output":{"type":"string"}}}}}},"errors":[{"code":-32004,"message":"Build failed"}]}]})); }
if method!="build" { return Err(crate::error(-32601,"cargo method not found")); } if method!="build" { return Err(crate::error(-32601,"cargo method not found")); }
let plugin=params["plugin"].as_str().ok_or_else(||crate::error(-32602,"plugin must be a string"))?; let plugin=params["plugin"].as_str().ok_or_else(||crate::error(-32602,"plugin must be a string"))?;
let options = BuildOptions { let options = BuildOptions {
@@ -187,8 +258,8 @@ impl Cargo {
test_timeout_secs: seconds(&params, "test_timeout_secs", DEFAULT_TEST_TIMEOUT_SECS)?, test_timeout_secs: seconds(&params, "test_timeout_secs", DEFAULT_TEST_TIMEOUT_SECS)?,
}; };
match builder.build_with_options(&builder.world.plugin_dir(plugin).join("source"), options).await { match builder.build_with_options(&builder.world.plugin_dir(plugin).join("source"), options).await {
Ok(output)=>Ok(json!({"wasm":format!("/{}",output.wasm.strip_prefix(&builder.world.root).unwrap().display()),"tests":output.tests,"warnings":output.warnings})), Ok(output)=>Ok(json!({"wasm":format!("/{}",output.wasm.strip_prefix(&builder.world.root).unwrap().display()),"tests":output.tests,"warnings":output.warnings,"logs":output.logs})),
Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"reason":failure.reason,"tests":failure.tests,"output":failure.output,"elapsed_secs":failure.elapsed_secs,"step":failure.step}})), Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"reason":failure.reason,"tests":failure.tests,"output":failure.output,"elapsed_secs":failure.elapsed_secs,"step":failure.step,"logs":failure.logs}})),
} }
}) })
})); }));
@@ -203,6 +274,26 @@ impl Cargo {
&self, &self,
source: &Path, source: &Path,
options: BuildOptions, options: BuildOptions,
) -> std::result::Result<BuildOutput, BuildFailure> {
let logs = LogFiles::create(&self.world.root)?;
let result = self.build_logged(source, options, &logs).await;
let info = logs.info(&self.world.root)?;
match result {
Ok(mut output) => {
output.logs = Some(info);
Ok(output)
}
Err(mut failure) => {
failure.logs = Some(info);
Err(failure)
}
}
}
async fn build_logged(
&self,
source: &Path,
options: BuildOptions,
logs: &LogFiles,
) -> std::result::Result<BuildOutput, BuildFailure> { ) -> std::result::Result<BuildOutput, BuildFailure> {
if options.timeout_secs == 0 || options.test_timeout_secs == 0 { if options.timeout_secs == 0 || options.test_timeout_secs == 0 {
return Err(anyhow::anyhow!("timeouts must be at least 1 second").into()); return Err(anyhow::anyhow!("timeouts must be at least 1 second").into());
@@ -285,7 +376,15 @@ impl Cargo {
"target.wasm32-wasip2.runner={}", "target.wasm32-wasip2.runner={}",
json!([self.runner, "test-runner"]) json!([self.runner, "test-runner"])
)); ));
let output = run_command(test, started, deadline, "tests", TestResults::default()).await?; let output = run_command(
test,
started,
deadline,
"tests",
TestResults::default(),
logs,
)
.await?;
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let tests = TestResults { let tests = TestResults {
@@ -299,6 +398,7 @@ impl Cargo {
}; };
if !output.status.success() { if !output.status.success() {
return Err(BuildFailure { return Err(BuildFailure {
logs: None,
reason: "WASI tests failed".into(), reason: "WASI tests failed".into(),
output: tests.output.clone(), output: tests.output.clone(),
tests, tests,
@@ -318,7 +418,15 @@ impl Cargo {
]) ])
.arg(&target) .arg(&target)
.current_dir(&work); .current_dir(&work);
let output = run_command(build, started, deadline, "release build", tests.clone()).await?; let output = run_command(
build,
started,
deadline,
"release build",
tests.clone(),
logs,
)
.await?;
let result = async { let result = async {
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
@@ -352,6 +460,7 @@ impl Cargo {
}) })
.await??; .await??;
Ok(BuildOutput { Ok(BuildOutput {
logs: None,
wasm: destination, wasm: destination,
warnings: strip_progress(&diagnostics), warnings: strip_progress(&diagnostics),
tests: tests.clone(), tests: tests.clone(),
@@ -359,6 +468,7 @@ impl Cargo {
} }
.await; .await;
result.map_err(|error: anyhow::Error| BuildFailure { result.map_err(|error: anyhow::Error| BuildFailure {
logs: None,
reason: "release build failed".into(), reason: "release build failed".into(),
output: format!("{error:#}"), output: format!("{error:#}"),
tests, tests,
@@ -384,28 +494,23 @@ async fn run_command(
deadline: Instant, deadline: Instant,
step: &'static str, step: &'static str,
mut tests: TestResults, mut tests: TestResults,
logs: &LogFiles,
) -> std::result::Result<std::process::Output, BuildFailure> { ) -> std::result::Result<std::process::Output, BuildFailure> {
command command
.process_group(0) .process_group(0)
.kill_on_drop(true) .kill_on_drop(true)
.stdin(std::process::Stdio::null()) .stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped()) // Child writes go straight to persistent files: no pipe buffers or reader
.stderr(std::process::Stdio::piped()); // tasks can be dropped on cancellation. Parsing happens only afterwards.
.stdout(logs.stdout.try_clone().context("clone stdout log")?)
.stderr(logs.stderr.try_clone().context("clone stderr log")?);
let out_start = logs.stdout.metadata().context("stat stdout log")?.len();
let err_start = logs.stderr.metadata().context("stat stderr log")?.len();
let mut child = command.spawn().context("start cargo")?; let mut child = command.spawn().context("start cargo")?;
let group = ProcessGroup(child.id().unwrap()); let group = ProcessGroup(child.id().unwrap());
let mut stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let (status, timed_out) = { let (status, timed_out) = {
let collect = async { let collect = child.wait();
let (status, _, _) = tokio::try_join!(
child.wait(),
stdout.read_to_end(&mut out),
stderr.read_to_end(&mut err)
)?;
Ok::<_, std::io::Error>(status)
};
tokio::pin!(collect); tokio::pin!(collect);
tokio::select! { tokio::select! {
biased; biased;
@@ -419,6 +524,10 @@ async fn run_command(
} }
}; };
let status = status.map_err(anyhow::Error::from)?; let status = status.map_err(anyhow::Error::from)?;
// Also stop any descendants that kept the streams open after cargo exited.
drop(group);
let out = logs.read(&logs.stdout_path, out_start)?;
let err = logs.read(&logs.stderr_path, err_start)?;
if timed_out { if timed_out {
let output = format!( let output = format!(
"{}\n{}", "{}\n{}",
@@ -441,6 +550,7 @@ async fn run_command(
tests.output = output.clone(); tests.output = output.clone();
} }
return Err(BuildFailure { return Err(BuildFailure {
logs: None,
reason: "timeout".into(), reason: "timeout".into(),
tests, tests,
output, output,
@@ -599,6 +709,103 @@ fn strip_progress(output: &str) -> String {
mod timeout_tests { mod timeout_tests {
use super::*; use super::*;
/// Streams retain exact bytes, including non-UTF8, progress and JSON; phases append.
#[tokio::test]
async fn raw_streams_append_before_parsing() {
let directory = tempfile::tempdir().unwrap();
let logs = LogFiles::create(directory.path()).unwrap();
for _ in 0..2 {
let mut command = Command::new("sh");
command.args(["-c", "printf '\\377raw\\r'; printf ' Compiling progress\\n{\"reason\":\"compiler-artifact\"}\\n' >&2; exit 3"]);
let now = Instant::now();
let output = tokio::time::timeout(
Duration::from_secs(5),
run_command(
command,
now,
now + Duration::from_secs(3),
"tests",
TestResults::default(),
&logs,
),
)
.await
.unwrap()
.unwrap();
assert!(!output.status.success());
assert_eq!(output.stdout, b"\xffraw\r");
assert!(String::from_utf8_lossy(&output.stderr).contains("Compiling progress"));
}
assert_eq!(
std::fs::read(&logs.stdout_path).unwrap(),
b"\xffraw\r\xffraw\r"
);
let info = logs.info(directory.path()).unwrap();
assert_eq!(info.stdout.bytes, 10);
assert_eq!(info.total_bytes, info.stdout.bytes + info.stderr.bytes);
assert!(info.stdout.path.starts_with("/logs/cargo/"));
assert!(!directory.path().join("state/cargo").exists());
}
/// Cancellation leaves kernel-written data intact, even without a newline.
#[tokio::test]
async fn cancelled_command_keeps_logs() {
use notify::Watcher;
let directory = tempfile::tempdir().unwrap();
let logs = LogFiles::create(directory.path()).unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event {
if event
.paths
.iter()
.any(|path| path.file_name().is_some_and(|name| name == "ready"))
{
let _ = tx.send(());
}
}
})
.unwrap();
watcher
.watch(directory.path(), notify::RecursiveMode::NonRecursive)
.unwrap();
let mut command = Command::new("sh");
command.args(["-c", "printf 'partial\\377'; printf 'stderr\\r' >&2; touch \"$1/ready\"; exec sleep 1000", "sh"]).arg(directory.path());
let now = Instant::now();
let task = tokio::spawn(async move {
run_command(
command,
now,
now + Duration::from_secs(60),
"tests",
TestResults::default(),
&logs,
)
.await
});
tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.unwrap()
.unwrap();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
let paths: Vec<_> = std::fs::read_dir(directory.path().join("logs/cargo"))
.unwrap()
.flat_map(|day| std::fs::read_dir(day.unwrap().path()).unwrap())
.map(|file| file.unwrap().path())
.collect();
assert_eq!(paths.len(), 2);
for path in paths {
let expected: &[u8] = if path.to_string_lossy().ends_with(".stdout.log") {
b"partial\xff"
} else {
b"stderr\r"
};
assert_eq!(std::fs::read(path).unwrap(), expected);
}
}
/// Deadline errors retain the shared start time, phase, test totals, and output tail. /// Deadline errors retain the shared start time, phase, test totals, and output tail.
#[tokio::test] #[tokio::test]
async fn timeout_diagnostics() { async fn timeout_diagnostics() {
@@ -607,6 +814,8 @@ mod timeout_tests {
"-c", "-c",
"i=0; while [ $i -lt 100 ]; do echo diagnostic-$i; i=$((i+1)); done; exec sleep 1000", "i=0; while [ $i -lt 100 ]; do echo diagnostic-$i; i=$((i+1)); done; exec sleep 1000",
]); ]);
let directory = tempfile::tempdir().unwrap();
let logs = LogFiles::create(directory.path()).unwrap();
let now = Instant::now(); let now = Instant::now();
let failure = run_command( let failure = run_command(
command, command,
@@ -618,6 +827,7 @@ mod timeout_tests {
failed: 0, failed: 0,
output: "successful tests".into(), output: "successful tests".into(),
}, },
&logs,
) )
.await .await
.unwrap_err(); .unwrap_err();
@@ -629,5 +839,8 @@ mod timeout_tests {
assert_eq!(failure.output.lines().count(), 50); assert_eq!(failure.output.lines().count(), 50);
assert!(failure.output.contains("diagnostic-99")); assert!(failure.output.contains("diagnostic-99"));
assert!(!failure.output.contains("diagnostic-0\n")); assert!(!failure.output.contains("diagnostic-0\n"));
let raw = std::fs::read_to_string(&logs.stdout_path).unwrap();
assert_eq!(raw.lines().count(), 100);
assert!(raw.starts_with("diagnostic-0\n"));
} }
} }
+37
View File
@@ -73,8 +73,28 @@ async fn build(world: &TestWorld, name: &str) -> Result<Value, Value> {
.register(&host); .register(&host);
let result = host.call("cargo.build", json!({"plugin":name})).await; let result = host.call("cargo.build", json!({"plugin":name})).await;
host.stop().await; host.stop().await;
let logs = match &result {
Ok(output) => &output["logs"],
Err(error) => &error["data"]["logs"],
};
check_logs(world, logs);
result result
} }
/// RPC paths point at full raw streams, outside pruned build folders.
fn check_logs(world: &TestWorld, logs: &Value) {
let mut total = 0;
for stream in ["stdout", "stderr"] {
let path = logs[stream]["path"].as_str().expect("log path");
assert!(path.starts_with("/logs/cargo/"));
assert!(path.ends_with(&format!(".{stream}.log")));
let bytes = std::fs::metadata(world.0.join(path.trim_start_matches('/')))
.unwrap()
.len();
assert_eq!(logs[stream]["bytes"], bytes);
total += bytes;
}
assert_eq!(logs["total_bytes"], total);
}
struct Valid { struct Valid {
world: TestWorld, world: TestWorld,
result: Value, result: Value,
@@ -206,6 +226,20 @@ async fn build_output_and_warnings() {
.unwrap() .unwrap()
.contains("fixture build warning") .contains("fixture build warning")
); );
let logs = &valid.result["logs"];
let raw = |stream: &str| {
std::fs::read_to_string(
valid.world.0.join(
logs[stream]["path"]
.as_str()
.unwrap()
.trim_start_matches('/'),
),
)
.unwrap()
};
assert!(raw("stdout").contains("\"reason\":\"compiler-artifact\""));
assert!(raw("stderr").contains("Finished "));
let output = valid.result["tests"]["output"].as_str().unwrap(); let output = valid.result["tests"]["output"].as_str().unwrap();
assert!(output.contains("INTEGRATION_CASE")); assert!(output.contains("INTEGRATION_CASE"));
assert!(!output.contains("Compiling ")); assert!(!output.contains("Compiling "));
@@ -277,6 +311,7 @@ async fn native_discovery_and_error_paths() {
.await .await
.unwrap_err(); .unwrap_err();
assert_eq!(error["code"], -32004); assert_eq!(error["code"], -32004);
check_logs(&world, &error["data"]["logs"]);
assert!( assert!(
error["data"]["output"] error["data"]["output"]
.as_str() .as_str()
@@ -721,6 +756,7 @@ async fn build_timeout_and_queued_build() {
assert_eq!(error["code"], -32004); assert_eq!(error["code"], -32004);
assert_eq!(error["message"], "timeout"); assert_eq!(error["message"], "timeout");
assert_eq!(error["data"]["reason"], "timeout"); assert_eq!(error["data"]["reason"], "timeout");
check_logs(&world, &error["data"]["logs"]);
assert_eq!(error["data"]["step"], "tests"); assert_eq!(error["data"]["step"], "tests");
assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0); assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0);
assert!(!error["data"]["output"].as_str().unwrap().is_empty()); assert!(!error["data"]["output"].as_str().unwrap().is_empty());
@@ -776,6 +812,7 @@ fn main() {
.unwrap_err(); .unwrap_err();
assert_eq!(error["code"], -32004); assert_eq!(error["code"], -32004);
assert_eq!(error["data"]["reason"], "timeout"); assert_eq!(error["data"]["reason"], "timeout");
check_logs(&world, &error["data"]["logs"]);
assert_eq!(error["data"]["step"], "release build"); assert_eq!(error["data"]["step"], "release build");
assert_eq!(error["data"]["tests"]["passed"], 1); assert_eq!(error["data"]["tests"]["passed"], 1);
assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0); assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0);