use std::io::Write;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
use crate::common::Sandbox;
fn source_host() -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_onetaskgraph"));
command.args(["plugin-serve", "in-memory"]);
command
}
#[test]
fn plugin_serve_is_hidden_from_normal_help() {
let output = Command::new(env!("CARGO_BIN_EXE_onetaskgraph"))
.arg("--help")
.output()
.expect("the main command runs");
assert!(output.status.success());
let help = String::from_utf8(output.stdout).expect("help is UTF-8");
assert!(
!help.contains("plugin-serve"),
"hidden command leaked: {help}"
);
}
#[test]
fn plugin_serve_refuses_a_source_this_build_does_not_have() {
let output = Command::new(env!("CARGO_BIN_EXE_onetaskgraph"))
.args(["plugin-serve", "missing"])
.output()
.expect("the main command runs");
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
let complaint = String::from_utf8(output.stderr).expect("diagnostic is UTF-8");
assert!(
complaint.contains("no plugin of this build is called \"missing\""),
"{complaint}"
);
assert!(complaint.contains("in-memory"), "{complaint}");
}
fn handshake() -> Value {
json!({
"id": "0",
"method": "initialize",
"params": {
"protocol_version": 2,
"engine": {"name": "onetaskgraph", "version": "0.1.0"},
"source_name": "work",
"config": {"tasks": [{
"id": "T-1", "title": "Alpha",
"status": {"category": "todo", "name": "Todo"}, "labels": []
}]},
"secrets": {}
}
})
}
#[test]
fn the_shipped_host_answers_a_connection_on_its_standard_input_and_exits_zero() {
let mut child = source_host()
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the host runs");
let mut input = child.stdin.take().expect("stdin was piped");
writeln!(input, "{}", handshake()).expect("the host is listening");
writeln!(
input,
"{}",
json!({"id": "1", "method": "labels", "params": {"page": {"limit": 5}}})
)
.expect("the host is listening");
drop(input);
let output = child.wait_with_output().expect("the host finishes");
assert_eq!(output.status.code(), Some(0), "the host exits cleanly");
let answered = String::from_utf8(output.stdout).expect("responses are UTF-8");
let lines: Vec<Value> = answered
.lines()
.map(|line| serde_json::from_str(line).expect("one JSON object per line"))
.collect();
assert_eq!(lines.len(), 2, "one answer per request: {answered}");
assert_eq!(lines[0]["id"], "0");
assert_eq!(lines[0]["result"]["protocol_version"], 2);
assert_eq!(lines[0]["result"]["kind"], "in-memory");
assert_eq!(lines[1]["id"], "1");
assert!(lines[1]["result"]["items"].is_array(), "{answered}");
assert!(
String::from_utf8_lossy(&output.stderr).is_empty(),
"a successful call writes nothing to standard error (§1)"
);
}
#[test]
fn the_shipped_host_reports_malformed_plugin_settings_on_the_wire() {
let mut asked = handshake();
asked["params"]["config"] = json!({"tasks": "not a task list"});
let mut child = source_host()
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("the host runs");
let mut input = child.stdin.take().expect("stdin was piped");
writeln!(input, "{asked}").expect("the host is listening");
drop(input);
let output = child.wait_with_output().expect("the host finishes");
assert_eq!(output.status.code(), Some(0));
let answered = String::from_utf8(output.stdout).expect("UTF-8");
let refusal: Value = serde_json::from_str(answered.trim()).expect("one JSON object");
assert_eq!(refusal["id"], "0");
assert_eq!(refusal["error"]["kind"], "config");
assert!(
refusal["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("source work") && message.contains("sequence")),
"{answered}"
);
}
#[test]
fn a_source_with_no_documents_declares_so_and_refuses_a_document_read_over_a_real_pipe() {
let mut child = source_host()
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the host runs");
let mut input = child.stdin.take().expect("stdin was piped");
writeln!(input, "{}", handshake()).expect("the host is listening");
for asked in [
json!({"id": "1", "method": "get_document", "params": {"id": "D-1"}}),
json!({"id": "2", "method": "query_documents", "params": {
"query": {
"text": null,
"labels": {"any_of": [], "all_of": [], "none_of": []},
"project": "any"
},
"page": {"cursor": null, "limit": 5}
}}),
] {
writeln!(input, "{asked}").expect("the host is listening");
}
drop(input);
let output = child.wait_with_output().expect("the host finishes");
assert_eq!(output.status.code(), Some(0), "the host exits cleanly");
let answered = String::from_utf8(output.stdout).expect("responses are UTF-8");
let lines: Vec<Value> = answered
.lines()
.map(|line| serde_json::from_str(line).expect("one JSON object per line"))
.collect();
assert_eq!(lines.len(), 3, "one answer per request: {answered}");
assert_eq!(
lines[0]["result"]["capabilities"]["documents"],
json!("unsupported"),
"{answered}"
);
for refusal in &lines[1..] {
assert!(
refusal.get("result").is_none(),
"a document read must not be answered at all: {refusal}"
);
assert_eq!(refusal["error"]["kind"], json!("refused"), "{refusal}");
assert_eq!(
refusal["error"]["message"],
json!("the in-memory plugin has no documents"),
"the refusal names the plugin behind the pipe: {refusal}"
);
}
}
#[test]
fn a_protocol_version_the_shipped_host_does_not_know_is_refused_and_it_exits_zero() {
let mut asked = handshake();
asked["params"]["protocol_version"] = json!(3);
let mut child = source_host()
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("the host runs");
let mut input = child.stdin.take().expect("stdin was piped");
writeln!(input, "{asked}").expect("the host is listening");
drop(input);
let output = child.wait_with_output().expect("the host finishes");
assert_eq!(output.status.code(), Some(0));
let answered = String::from_utf8(output.stdout).expect("UTF-8");
let refusal: Value = serde_json::from_str(answered.trim()).expect("one JSON object");
assert_eq!(refusal["error"]["kind"], "config");
let message = refusal["error"]["message"]
.as_str()
.expect("a message to read");
assert!(
message.contains("version 3") && message.contains("version 2"),
"{message}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_host_that_cannot_write_its_answer_exits_one_and_says_so_on_standard_error() {
use std::fs::OpenOptions;
let full = OpenOptions::new()
.write(true)
.open("/dev/full")
.expect("/dev/full exists on Linux");
let mut child = source_host()
.stdin(Stdio::piped())
.stdout(Stdio::from(full))
.stderr(Stdio::piped())
.spawn()
.expect("the host runs");
let mut input = child.stdin.take().expect("stdin was piped");
writeln!(input, "{}", handshake()).expect("the host is listening");
drop(input);
let output = child.wait_with_output().expect("the host finishes");
assert_eq!(
output.status.code(),
Some(1),
"a stream it cannot write is a failure, not a usage mistake"
);
let complaint = String::from_utf8_lossy(&output.stderr);
assert!(
complaint.contains("onetaskgraph:"),
"the program names itself: {complaint}"
);
assert!(!complaint.contains("panicked"), "{complaint}");
}
struct Hosted {
kind: &'static str,
config: Value,
secrets: Value,
}
impl Hosted {
fn every_kind(sandbox: &Sandbox) -> Vec<Self> {
let far = json!([{"id": "elsewhere:P-9", "kind": "project"}]);
vec![
Self {
kind: "linear",
config: crate::fixtures::linear_recording(sandbox, far.clone()),
secrets: json!({"LINEAR_API_KEY": "fixture-key"}),
},
Self {
kind: "github-projects",
config: crate::fixtures::github_projects_recording(sandbox, far),
secrets: json!({"GITHUB_PROJECTS_FIXTURE_TOKEN": "test-token"}),
},
]
}
fn connection(&self) -> (Command, Value) {
let mut command = Command::new(env!("CARGO_BIN_EXE_onetaskgraph"));
command.args(["plugin-serve", self.kind]);
let handshake = json!({
"id": "0",
"method": "initialize",
"params": {
"protocol_version": 2,
"engine": {"name": "onetaskgraph", "version": "0.1.0"},
"source_name": "work",
"config": self.config,
"secrets": self.secrets
}
});
(command, handshake)
}
}
fn answers(mut command: Command, handshake: &Value, requests: &[Value]) -> Vec<Value> {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the host runs");
let mut input = child.stdin.take().expect("stdin was piped");
writeln!(input, "{handshake}").expect("the host is listening");
for request in requests {
writeln!(input, "{request}").expect("the host is listening");
}
drop(input);
let output = child.wait_with_output().expect("the host finishes");
assert_eq!(output.status.code(), Some(0), "the host exits cleanly");
let answered = String::from_utf8(output.stdout).expect("responses are UTF-8");
let lines: Vec<Value> = answered
.lines()
.map(|line| serde_json::from_str(line).expect("one JSON object per line"))
.collect();
assert_eq!(lines.len(), requests.len() + 1, "{answered}");
assert_eq!(lines[0]["result"]["protocol_version"], 2, "{answered}");
lines[1..].to_vec()
}
fn resumed(direction: &str, cursor: &str) -> Value {
json!({
"id": "1",
"method": "task_dependencies",
"params": {
"id": "T-1",
"direction": direction,
"page": {"cursor": cursor, "limit": 50}
}
})
}
fn refusal(hosted: &Hosted, request: Value) -> String {
let (command, handshake) = hosted.connection();
let answered = answers(command, &handshake, &[request]);
let answer = &answered[0];
assert!(
answer.get("result").is_none(),
"{}: this must not be answered: {answer}",
hosted.kind
);
answer["error"]["message"]
.as_str()
.unwrap_or_else(|| panic!("{}: an error carries a message: {answer}", hosted.kind))
.to_owned()
}
#[test]
fn the_shipped_host_refuses_a_recorded_cursor_no_walk_of_its_own_reported() {
let sandbox = Sandbox::new();
let cursor = "onetaskgraph.depends_on:0";
for hosted in Hosted::every_kind(&sandbox) {
let reversed = refusal(&hosted, resumed("depended-on-by", cursor));
assert!(reversed.contains(cursor), "{}: {reversed}", hosted.kind);
assert!(
reversed.contains("reverse dependency read"),
"{}: {reversed}",
hosted.kind
);
let unreadable = refusal(&hosted, resumed("depends-on", "onetaskgraph.depends_on:x"));
assert!(
unreadable.contains("is not a recorded-edge cursor"),
"{}: {unreadable}",
hosted.kind
);
let (command, handshake) = hosted.connection();
let answered = answers(command, &handshake, &[resumed("depends-on", cursor)]);
let items = &answered[0]["result"]["items"];
assert_eq!(items[0]["from"]["id"], "T-1", "{}: {items}", hosted.kind);
assert_eq!(
items[0]["to"]["id"], "elsewhere:P-9",
"{}: {items}",
hosted.kind
);
}
}
fn removal_handshake(capabilities: Value) -> Value {
json!({
"id": "0",
"method": "initialize",
"params": {
"protocol_version": 2,
"engine": {"name": "onetaskgraph", "version": "0.1.0"},
"source_name": "work",
"config": {
"tasks": [{
"id": "T-1", "title": "Alpha",
"status": {"category": "todo", "name": "Todo"},
"labels": [], "project": "P-1"
}],
"projects": [{
"id": "P-1", "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": []
}],
"capabilities": capabilities
},
"secrets": {}
}
})
}
#[test]
fn the_shipped_host_removes_a_task_and_a_project_and_they_are_gone_afterwards() {
let answered = answers(
source_host(),
&removal_handshake(json!({})),
&[
json!({"id": "1", "method": "delete_task", "params": {"id": "T-1"}}),
json!({"id": "2", "method": "get_task", "params": {"id": "T-1"}}),
json!({"id": "3", "method": "delete_project", "params": {"id": "P-1"}}),
json!({"id": "4", "method": "get_project", "params": {"id": "P-1"}}),
],
);
assert_eq!(answered[0]["result"], json!({}), "{answered:?}");
assert_eq!(answered[1]["result"]["task"], Value::Null, "{answered:?}");
assert_eq!(answered[2]["result"], json!({}), "{answered:?}");
assert_eq!(
answered[3]["result"]["project"],
Value::Null,
"{answered:?}"
);
}
#[test]
fn a_removal_the_hosted_source_refuses_crosses_the_wire_as_its_own_reason() {
let answered = answers(
source_host(),
&removal_handshake(json!({"undeletable_ids": ["T-1"]})),
&[
json!({"id": "1", "method": "delete_task", "params": {"id": "T-1"}}),
json!({"id": "2", "method": "get_task", "params": {"id": "T-1"}}),
],
);
assert_eq!(answered[0]["error"]["kind"], "refused", "{answered:?}");
let message = answered[0]["error"]["message"]
.as_str()
.expect("a message to read");
assert!(
message.contains("will not remove T-1"),
"the plugin's own reason names the item: {message}"
);
assert_eq!(answered[1]["result"]["task"]["id"], "T-1", "{answered:?}");
}