#![cfg(unix)]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests and benches use unwrap and expect to keep fixture setup concise"
)]
use crate::common::{fallow_bin, scrub_analysis_env};
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
use std::fmt::Write as _;
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
const MIN_REPORT_BYTES: usize = 512 * 1024;
const FILE_COUNT: usize = 400;
const EXPORTS_PER_FILE: usize = 20;
const PIPE_FULL_BYTES: u64 = 16 * 1024;
const FULL_PIPE_GRACE: Duration = Duration::from_millis(200);
const POLL_INTERVAL: Duration = Duration::from_millis(10);
const ANALYSIS_TIMEOUT: Duration = Duration::from_secs(120);
fn write_large_project(root: &Path) {
std::fs::write(
root.join("package.json"),
r#"{"name":"large-report","version":"1.0.0","main":"src/index.ts"}"#,
)
.expect("write package.json");
let src = root.join("src");
std::fs::create_dir_all(&src).expect("create src");
let mut index = String::new();
for file in 0..FILE_COUNT {
let mut body = String::new();
for export in 0..EXPORTS_PER_FILE {
writeln!(
body,
"export const unusedValueWithALongName_{file}_{export} = {export};"
)
.expect("write to string");
}
writeln!(body, "export const used_{file} = 1;").expect("write to string");
std::fs::write(src.join(format!("module_{file}.ts")), body).expect("write module");
writeln!(
index,
"import {{ used_{file} }} from './module_{file}';\nconsole.log(used_{file});"
)
.expect("write to string");
}
std::fs::write(src.join("index.ts"), index).expect("write index");
}
fn dead_code_command(root: &Path) -> Command {
let mut cmd = Command::new(fallow_bin());
scrub_analysis_env(&mut cmd);
cmd.arg("dead-code")
.arg("--root")
.arg(root)
.args(["--format", "json", "--quiet", "--no-cache"])
.env("NO_COLOR", "1")
.env("FALLOW_TELEMETRY_DISABLED", "1")
.stderr(Stdio::piped());
cmd
}
fn set_nonblocking(fd: &impl rustix::fd::AsFd) {
let flags = fcntl_getfl(fd).expect("read the file status flags");
fcntl_setfl(fd, flags | OFlags::NONBLOCK).expect("set O_NONBLOCK");
}
fn wait_until_pipe_is_full(reader: &std::io::PipeReader, child: &mut std::process::Child) {
let start = std::time::Instant::now();
loop {
let pending = rustix::io::ioctl_fionread(reader).expect("read the pipe fill level");
if pending >= PIPE_FULL_BYTES {
std::thread::sleep(FULL_PIPE_GRACE);
return;
}
if child.try_wait().expect("check fallow").is_some() {
return;
}
assert!(
start.elapsed() < ANALYSIS_TIMEOUT,
"fallow did not fill the pipe in {ANALYSIS_TIMEOUT:?}"
);
std::thread::sleep(POLL_INTERVAL);
}
}
#[test]
fn json_report_is_complete_when_stdout_is_nonblocking() {
let dir = tempfile::tempdir().expect("tempdir");
write_large_project(dir.path());
let (mut reader, writer) = std::io::pipe().expect("create pipe");
set_nonblocking(&writer);
let mut cmd = dead_code_command(dir.path());
cmd.stdout(Stdio::from(writer));
let mut child = cmd.spawn().expect("spawn fallow");
drop(cmd);
wait_until_pipe_is_full(&reader, &mut child);
let mut stdout = Vec::new();
reader.read_to_end(&mut stdout).expect("read stdout");
let mut stderr = String::new();
child
.stderr
.take()
.expect("piped stderr")
.read_to_string(&mut stderr)
.expect("read stderr");
let status = child.wait().expect("wait for fallow");
assert!(
stdout.len() > MIN_REPORT_BYTES,
"the report must be larger than {MIN_REPORT_BYTES} bytes, got {} bytes (stderr: {stderr})",
stdout.len()
);
let parsed: serde_json::Value = serde_json::from_slice(&stdout).unwrap_or_else(|e| {
panic!(
"stdout must be complete JSON, got {} bytes: {e} (stderr: {stderr})",
stdout.len()
)
});
assert!(parsed.is_object(), "the report must be a JSON object");
assert_eq!(
status.code(),
Some(1),
"unused exports give exit 1 (stderr: {stderr})"
);
}
#[test]
fn closed_stdout_reader_exits_without_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
write_large_project(dir.path());
let (reader, writer) = std::io::pipe().expect("create pipe");
drop(reader);
let mut cmd = dead_code_command(dir.path());
cmd.stdout(Stdio::from(writer));
let output = cmd.output().expect("run fallow");
let stderr = String::from_utf8_lossy(&output.stderr);
let mut baseline = dead_code_command(dir.path());
baseline.stdout(Stdio::null());
let expected = baseline.output().expect("run fallow");
let expected_stderr = String::from_utf8_lossy(&expected.stderr);
assert_eq!(
output.status.code(),
expected.status.code(),
"a closed reader keeps the exit code of the analysis (stderr: {stderr})"
);
assert_eq!(
stderr, expected_stderr,
"a closed reader must not add output to stderr"
);
}
#[cfg(target_os = "linux")]
#[test]
fn stdout_write_error_exits_with_code_2() {
let dir = tempfile::tempdir().expect("tempdir");
write_large_project(dir.path());
let full = std::fs::OpenOptions::new()
.write(true)
.open("/dev/full")
.expect("open /dev/full");
let mut cmd = dead_code_command(dir.path());
cmd.stdout(Stdio::from(full));
let output = cmd.output().expect("run fallow");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(2), "stderr: {stderr}");
assert!(
stderr.contains("failed to write the report to stdout"),
"stderr must name the write failure, got: {stderr}"
);
}