mod json_doc;
use json_doc::{Json, field_text};
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
const RUNNER: &str = env!("CARGO_BIN_EXE_ahk");
const SURFACE: &str = include_str!("../../ahk/reference/ahk-v2.0.19.surface.tsv");
fn run(source: &str) -> (i32, Json) {
let mut child = Command::new(RUNNER)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the runner starts");
child
.stdin
.take()
.expect("the runner reads a script")
.write_all(source.as_bytes())
.expect("the script goes in");
let output = child.wait_with_output().expect("the runner finishes");
let text = String::from_utf8(output.stdout).expect("the report is utf-8");
let report = Json::parse(text.trim());
(output.status.code().unwrap_or(-1), report)
}
#[test]
fn a_script_answers_with_a_report() {
let (code, report) = run("MsgBox \"hello from the box\"\n");
assert_eq!(code, 0);
assert!(report.boolean("ok"));
assert_eq!(
report.array("output")[0].as_str(),
Some("hello from the box")
);
}
#[test]
fn a_script_that_does_not_parse_answers_with_the_line() {
let (code, report) = run("MsgBox \"unterminated\n");
assert_eq!(code, 2);
assert!(!report.boolean("ok"));
assert_eq!(report.number("error_line"), 1);
assert!(report.get("column").is_number());
}
#[test]
fn a_failing_script_answers_with_a_non_zero_exit() {
let (code, report) = run("x := 1 / 0\n");
assert_eq!(code, 1);
assert!(!report.boolean("ok"));
assert_eq!(report.text("error"), "Divide by zero.");
assert_eq!(report.number("error_line"), 1);
}
#[test]
fn the_report_carries_the_fields_the_client_reads() {
let (_code, report) = run("MsgBox \"hello\"\n");
let fields: Vec<&str> = report.names();
for field in [
"ok",
"steps",
"output",
"notes",
"error",
"error_line",
"exit_code",
"elapsed_ms",
"variables",
] {
assert!(fields.contains(&field), "the report has no {field}");
}
}
#[test]
fn the_exit_app_code_is_in_the_report_and_not_in_the_exit_code() {
let (code, report) = run("ExitApp 3\n");
assert_eq!(code, 0);
assert_eq!(report.number("exit_code"), 3);
}
#[test]
fn the_pid_file_is_written_while_the_script_runs_and_removed_after() {
let cache = std::env::temp_dir().join(format!("ahk-runner-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache);
std::fs::create_dir_all(&cache).expect("the cache directory");
let pid_file = cache.join("ahk").join("run.pid");
let mut child = Command::new(RUNNER)
.env("XDG_CACHE_HOME", &cache)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the runner starts");
child
.stdin
.take()
.expect("the runner reads a script")
.write_all(b"Sleep 2000\n")
.expect("the script goes in");
let mut written = None;
for _ in 0..100 {
if let Ok(text) = std::fs::read_to_string(&pid_file) {
written = Some(text);
break;
}
std::thread::sleep(Duration::from_millis(50));
}
let text = written.expect("the pid file is written while the script runs");
assert_eq!(text.trim(), child.id().to_string());
let output = child.wait_with_output().expect("the runner finishes");
assert!(output.status.success());
assert!(
!pid_file.exists(),
"the pid file is removed when the script ends"
);
let _ = std::fs::remove_dir_all(&cache);
}
#[test]
fn the_pid_file_does_not_land_in_the_working_directory() {
let cache = std::env::temp_dir().join(format!("ahk-runner-nohome-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache);
std::fs::create_dir_all(&cache).expect("the cache directory");
let _ = std::fs::remove_dir_all(".cache");
let mut child = Command::new(RUNNER)
.env_remove("XDG_CACHE_HOME")
.env_remove("HOME")
.env_remove("USERPROFILE")
.env("TMPDIR", &cache)
.env("TMP", &cache)
.env("TEMP", &cache)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the runner starts");
child
.stdin
.take()
.expect("the runner reads a script")
.write_all(b"MsgBox \"x\"\n")
.expect("the script goes in");
let output = child.wait_with_output().expect("the runner finishes");
assert!(output.status.success());
assert!(
!std::path::Path::new(".cache").exists(),
"the pid file landed in the working directory"
);
let _ = std::fs::remove_dir_all(&cache);
}
fn surface() -> (i32, String) {
let mut child = Command::new(RUNNER)
.arg("--surface")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the runner starts");
let _script = child.stdin.take().expect("the runner reads a script");
let stdout = child.stdout.take().expect("the runner writes the surface");
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let mut text = String::new();
let mut stdout = stdout;
let _ = stdout.read_to_string(&mut text);
let _ = sender.send(text);
});
let text = match receiver.recv_timeout(Duration::from_secs(30)) {
Ok(text) => text,
Err(_) => {
let _ = child.kill();
panic!("the runner did not answer the flag without a script on stdin");
}
};
let status = child.wait().expect("the runner finishes");
(status.code().unwrap_or(-1), text)
}
fn surface_json(dialect: &str) -> (i32, Json) {
let mut child = Command::new(RUNNER)
.arg("--surface")
.arg("--json")
.arg(format!("--dialect={dialect}"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the runner starts");
let _script = child.stdin.take().expect("the runner reads a script");
let stdout = child.stdout.take().expect("the runner writes the surface");
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let mut text = String::new();
let mut stdout = stdout;
let _ = stdout.read_to_string(&mut text);
let _ = sender.send(text);
});
let text = match receiver.recv_timeout(Duration::from_secs(30)) {
Ok(text) => text,
Err(_) => {
let _ = child.kill();
panic!("the runner did not answer the flag without a script on stdin");
}
};
let status = child.wait().expect("the runner finishes");
let report = Json::parse(text.trim());
(status.code().unwrap_or(-1), report)
}
#[test]
fn the_surface_flag_answers_with_json() {
let (code, report) = surface_json("v2");
assert_eq!(code, 0);
assert_eq!(report.text("dialect"), "v2");
assert_eq!(report.number("total"), 833);
assert_eq!(report.number("values"), 817);
assert_eq!(report.number("refusals"), 16);
assert_eq!(report.number("pending"), 0);
let entries = report.array("entries");
assert_eq!(entries.len(), 833);
let abs = entries
.iter()
.find(|entry| field_text(entry, "name") == "Abs")
.expect("the name is in the report");
assert_eq!(field_text(abs, "kind"), "function");
assert_eq!(field_text(abs, "state"), "value");
let windows = entries
.iter()
.find(|entry| field_text(entry, "name") == "ControlGetStyle")
.expect("the name is in the report");
assert_eq!(field_text(windows, "state"), "refusal");
assert!(
windows
.get("reason")
.map(|v| v.is_string())
.unwrap_or(false)
);
}
#[test]
fn the_surface_flag_answers_with_the_json_of_the_v1() {
let (code, report) = surface_json("v1");
assert_eq!(code, 0);
assert_eq!(report.text("dialect"), "v1");
assert_eq!(report.number("total"), 408);
assert_eq!(report.number("values"), 308);
assert_eq!(report.number("pending"), 0);
let entries = report.array("entries");
let is_set = entries
.iter()
.find(|entry| field_text(entry, "name") == "IsSet")
.expect("the name is in the report");
assert_eq!(field_text(is_set, "state"), "value");
let auto_trim = entries
.iter()
.find(|entry| field_text(entry, "name") == "AutoTrim")
.expect("the name is in the report");
assert_eq!(field_text(auto_trim, "state"), "value");
let set_format = entries
.iter()
.find(|entry| field_text(entry, "name") == "SetFormat")
.expect("the name is in the report");
assert_eq!(field_text(set_format, "state"), "value");
let string_case_sense = entries
.iter()
.find(|entry| field_text(entry, "name") == "StringCaseSense")
.expect("the name is in the report");
assert_eq!(field_text(string_case_sense, "state"), "value");
let transform = entries
.iter()
.find(|entry| field_text(entry, "name") == "Transform")
.expect("the name is in the report");
assert_eq!(field_text(transform, "state"), "value");
let var_set_capacity = entries
.iter()
.find(|entry| field_text(entry, "name") == "VarSetCapacity")
.expect("the name is in the report");
assert_eq!(field_text(var_set_capacity, "state"), "value");
let if_in_string = entries
.iter()
.find(|entry| field_text(entry, "name") == "IfInString")
.expect("the name is in the report");
assert_eq!(field_text(if_in_string, "state"), "value");
let if_not_in_string = entries
.iter()
.find(|entry| field_text(entry, "name") == "IfNotInString")
.expect("the name is in the report");
assert_eq!(field_text(if_not_in_string, "state"), "value");
let if_msg_box = entries
.iter()
.find(|entry| field_text(entry, "name") == "IfMsgBox")
.expect("the name is in the report");
assert_eq!(field_text(if_msg_box, "state"), "value");
let else_of_the_v1 = entries
.iter()
.find(|entry| field_text(entry, "name") == "Else")
.expect("the name is in the report");
assert_eq!(field_text(else_of_the_v1, "state"), "value");
let set_batch_lines = entries
.iter()
.find(|entry| field_text(entry, "name") == "SetBatchLines")
.expect("the name is in the report");
assert_eq!(field_text(set_batch_lines, "state"), "value");
assert!(
entries
.iter()
.all(|entry| field_text(entry, "state") != "pending"),
"the surface of the v1 has a name that is pending"
);
}
fn names_of_the_surface() -> usize {
SURFACE
.lines()
.filter(|line| {
!line.trim().is_empty() && !line.starts_with('#') && !line.starts_with("kind\t")
})
.count()
}
#[test]
fn the_surface_flag_answers_without_a_script() {
let (code, text) = surface();
assert_eq!(code, 0);
assert!(
!text.trim_start().starts_with('{'),
"the flag answered with a report of a script and not with the surface"
);
assert!(
text.contains("Abs function value"),
"the surface is not in the report"
);
}
#[test]
fn the_surface_has_a_line_for_every_name_of_v2() {
let (_code, text) = surface();
let lines: Vec<&str> = text.lines().collect();
let names = names_of_the_surface();
assert_eq!(
lines.len(),
names + 1,
"the surface has {names} names and the report has {} lines",
lines.len()
);
assert!(
lines[lines.len() - 1].contains("the surface of v2 has"),
"the report has no summary: {:?}",
lines[lines.len() - 1]
);
}
#[test]
fn a_name_that_the_tree_carries_is_reported_as_a_value() {
let (_code, text) = surface();
assert!(
text.lines().any(|line| line == "Abs function value"),
"the name that the tree answers is not reported as a value"
);
let line = text
.lines()
.find(|line| line.starts_with("ControlSendText command "))
.expect("the name is in the report");
match ahk::builtins::refusal_of("ControlSendText") {
Some(reason) => assert_eq!(line, format!("ControlSendText command refusal: {reason}")),
None => assert_eq!(line, "ControlSendText command value"),
}
}
fn a_test_directory(name: &str) -> std::path::PathBuf {
let directory = std::env::temp_dir().join(format!("ahk-runner-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("the test directory is made");
directory
}
#[test]
fn an_include_is_put_in_once() {
let directory = a_test_directory("include-once");
let included = directory.join("included.ahk");
std::fs::write(&included, "MsgBox \"included\"\n").expect("the included file is written");
let script = format!(
"#Include \"{}\"\n#Include \"{}\"\nMsgBox \"main\"\n",
included.display(),
included.display()
);
let (code, report) = run(&script);
assert_eq!(code, 0);
let output = report.array("output");
assert_eq!(output.len(), 2, "the file is put in once");
assert_eq!(output[0].as_str(), Some("included"));
assert_eq!(output[1].as_str(), Some("main"));
let _ = std::fs::remove_dir_all(&directory);
}
#[test]
fn an_include_again_is_put_in_every_time() {
let directory = a_test_directory("include-again");
let included = directory.join("included.ahk");
std::fs::write(&included, "MsgBox \"included\"\n").expect("the included file is written");
let script = format!(
"#IncludeAgain \"{}\"\n#IncludeAgain \"{}\"\nMsgBox \"main\"\n",
included.display(),
included.display()
);
let (code, report) = run(&script);
assert_eq!(code, 0);
let output = report.array("output");
assert_eq!(
output.len(),
3,
"the file is put in every time it is asked for"
);
assert_eq!(output[0].as_str(), Some("included"));
assert_eq!(output[1].as_str(), Some("included"));
assert_eq!(output[2].as_str(), Some("main"));
let _ = std::fs::remove_dir_all(&directory);
}
#[test]
fn an_include_again_after_an_include_puts_the_file_in_again() {
let directory = a_test_directory("include-again-after");
let included = directory.join("included.ahk");
std::fs::write(&included, "MsgBox \"included\"\n").expect("the included file is written");
let script = format!(
"#Include \"{path}\"\n#IncludeAgain \"{path}\"\nMsgBox \"main\"\n",
path = included.display()
);
let (code, report) = run(&script);
assert_eq!(code, 0);
assert_eq!(report.array("output").len(), 3);
let _ = std::fs::remove_dir_all(&directory);
}
#[test]
fn an_include_that_includes_itself_is_refused() {
let directory = a_test_directory("include-itself");
let file = directory.join("itself.ahk");
std::fs::write(&file, format!("#IncludeAgain \"{}\"\n", file.display()))
.expect("the file is written");
let script = format!("#IncludeAgain \"{}\"\n", file.display());
let (code, report) = run(&script);
assert_eq!(code, 2);
assert!(!report.boolean("ok"));
assert!(
report.text("error").contains("nest more than"),
"{:?}",
report.text("error")
);
let _ = std::fs::remove_dir_all(&directory);
}
#[test]
fn an_include_again_without_a_path_is_refused() {
let (code, report) = run("#IncludeAgain\n");
assert_eq!(code, 2);
assert!(report.text("error").contains("an include needs a path"));
}
#[test]
fn the_report_of_the_capability_says_the_numbers_of_the_tree() {
let (_, report) = surface_json("v2");
let document = include_str!("../../../docs/v2-capability-report.md");
for (number, what) in [
(report.number("total"), "entries"),
(report.number("values"), "answer with a value"),
(report.number("refusals"), "are refused"),
] {
let wanted = format!("**{number} {what}**");
assert!(
document.contains(&wanted),
"the report of the capability does not say {wanted}"
);
}
}