tests/body_input_test.rs
Ref: Size: 11.2 KiB History
//! Prose gets into git-collab through more than one door.
//!
//! Every command that takes `--body` must also accept `--body-file <path>`
//! and `--body-file -` for stdin, following git's own `-F` convention, and
//! must open `$EDITOR` when an interactive user gives no body at all.
//!
//! The load-bearing property throughout is that whatever goes in comes back
//! out *byte for byte*: trailing newlines, CRLF, tabs, non-ASCII and shell
//! metacharacters all survive the round trip through the event DAG. That is
//! the class of corruption nobody notices until they read a review three
//! weeks later.
mod common;
use common::TestRepo;
/// A body designed to be hostile to every layer it passes through: shell
/// metacharacters, quotes of both kinds, a `$`, a backtick, a blank line, a
/// tab, non-ASCII (including a combining mark and an emoji), a CRLF, and
/// two trailing newlines.
const NASTY: &str = "Line with `backticks` and $VARS and \"quotes\" and 'single'\n\nDoes this work? Yes * always!\n\tTabbed\r\nCRLF above\nnaïve café — ünïcödé 🎉 e\u{0301}\n\n";
/// Pull a JSON string field out of `... --json` output.
fn json_field(json: &str, pointer: &str) -> String {
let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
value
.pointer(pointer)
.unwrap_or_else(|| panic!("no {} in {}", pointer, json))
.as_str()
.expect("string field")
.to_string()
}
// ===========================================================================
// --body-file <path>
// ===========================================================================
#[test]
fn issue_comment_reads_body_from_a_file_byte_for_byte() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("nasty body");
let path = repo.dir.path().join("body.txt");
std::fs::write(&path, NASTY).unwrap();
repo.run_ok(&["issue", "comment", &id, "-F", path.to_str().unwrap()]);
let json = repo.run_ok(&["issue", "show", &id, "--json"]);
assert_eq!(json_field(&json, "/comments/0/body"), NASTY);
}
#[test]
fn body_file_has_a_long_form_too() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("long form");
let path = repo.dir.path().join("body.txt");
std::fs::write(&path, "from the long form\n").unwrap();
repo.run_ok(&[
"issue",
"comment",
&id,
"--body-file",
path.to_str().unwrap(),
]);
let json = repo.run_ok(&["issue", "show", &id, "--json"]);
assert_eq!(
json_field(&json, "/comments/0/body"),
"from the long form\n"
);
}
#[test]
fn missing_body_file_reports_the_path() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("missing file");
let err = repo.run_err(&["issue", "comment", &id, "-F", "/nonexistent/body.txt"]);
assert!(
err.contains("/nonexistent/body.txt"),
"error should name the path it could not read: {}",
err
);
}
// ===========================================================================
// --body-file - (stdin)
// ===========================================================================
#[test]
fn issue_comment_reads_body_from_stdin_byte_for_byte() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("stdin body");
repo.run_stdin_ok(&["issue", "comment", &id, "-F", "-"], NASTY.as_bytes());
let json = repo.run_ok(&["issue", "show", &id, "--json"]);
assert_eq!(json_field(&json, "/comments/0/body"), NASTY);
}
#[test]
fn patch_review_reads_body_from_stdin() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("review from stdin");
repo.run_stdin_ok(
&["patch", "review", &id, "-v", "comment", "-F", "-"],
NASTY.as_bytes(),
);
let json = repo.run_ok(&["patch", "show", &id, "--json"]);
assert_eq!(json_field(&json, "/reviews/0/body"), NASTY);
}
#[test]
fn patch_comment_reads_body_from_stdin() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("comment from stdin");
repo.run_stdin_ok(&["patch", "comment", &id, "-F", "-"], NASTY.as_bytes());
let json = repo.run_ok(&["patch", "show", &id, "--json"]);
assert_eq!(json_field(&json, "/comments/0/body"), NASTY);
}
#[test]
fn patch_inline_comment_reads_body_from_stdin() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("inline from stdin");
repo.run_stdin_ok(
&[
"patch",
"comment",
&id,
// The anchor must resolve in the revision; `patch_create` commits
// `<title>.txt`.
"--file",
"inline-from-stdin.txt",
"--line",
"1",
"-F",
"-",
],
NASTY.as_bytes(),
);
let json = repo.run_ok(&["patch", "show", &id, "--json"]);
assert_eq!(json_field(&json, "/inline_comments/0/body"), NASTY);
}
#[test]
fn stdin_body_survives_on_every_command_that_takes_one() {
let repo = TestRepo::new("Alice", "alice@example.com");
// issue open
let out = repo.run_stdin_ok(
&["issue", "open", "-t", "opened from stdin", "-F", "-"],
NASTY.as_bytes(),
);
let issue_id = out
.trim()
.strip_prefix("Opened issue ")
.unwrap()
.to_string();
let json = repo.run_ok(&["issue", "show", &issue_id, "--json"]);
assert_eq!(json_field(&json, "/body"), NASTY, "issue open");
// issue edit
repo.run_stdin_ok(
&["issue", "edit", &issue_id, "-F", "-"],
b"edited from stdin\n\n",
);
let json = repo.run_ok(&["issue", "show", &issue_id, "--json"]);
assert_eq!(json_field(&json, "/body"), "edited from stdin\n\n");
// patch create
repo.git(&["checkout", "-b", "stdin-patch"]);
repo.commit_file("stdin.txt", "x", "stdin patch commit");
let out = repo.run_stdin_ok(
&[
"patch",
"create",
"-t",
"created from stdin",
"-B",
"stdin-patch",
"-F",
"-",
],
NASTY.as_bytes(),
);
repo.git(&["checkout", "main"]);
let patch_id = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
let json = repo.run_ok(&["patch", "show", &patch_id, "--json"]);
assert_eq!(json_field(&json, "/body"), NASTY, "patch create");
// patch revise
repo.git(&["checkout", "stdin-patch"]);
repo.commit_file("stdin2.txt", "y", "second stdin commit");
repo.run_stdin_ok(&["patch", "revise", &patch_id, "-F", "-"], NASTY.as_bytes());
repo.git(&["checkout", "main"]);
let json = repo.run_ok(&["patch", "show", &patch_id, "--json"]);
assert_eq!(
json_field(&json, "/revisions/1/body"),
NASTY,
"patch revise"
);
}
// ===========================================================================
// Conflicts and refusals
// ===========================================================================
#[test]
fn body_and_body_file_together_are_refused() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("conflict");
let path = repo.dir.path().join("body.txt");
std::fs::write(&path, "from file").unwrap();
let err = repo.run_err(&[
"issue",
"comment",
&id,
"-b",
"from flag",
"-F",
path.to_str().unwrap(),
]);
assert!(
err.contains("body") && err.contains("body-file"),
"error should name both options: {}",
err
);
}
/// The failure mode that matters most for scripts and agents: with no body
/// and no terminal there is nothing to open an editor on, so the command must
/// say so and exit rather than block forever on a detached editor.
#[test]
fn a_missing_body_without_a_terminal_is_an_error_not_a_hang() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("no body");
let err = repo.run_err(&["issue", "comment", &id]);
assert!(
err.contains("--body-file"),
"error should point at the non-interactive alternatives: {}",
err
);
}
#[test]
fn an_empty_body_file_is_refused_rather_than_recorded() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("empty file");
let path = repo.dir.path().join("empty.txt");
std::fs::write(&path, "").unwrap();
let err = repo.run_err(&["issue", "comment", &id, "-F", path.to_str().unwrap()]);
assert!(
err.to_lowercase().contains("empty"),
"error should say the body was empty: {}",
err
);
}
// ===========================================================================
// $EDITOR
// ===========================================================================
/// An interactive user who gives no body gets an editor, and whatever they
/// save is the body — verbatim, including the trailing blank line.
#[test]
fn a_missing_body_opens_the_editor_and_keeps_its_bytes() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("editor body");
// A fake editor that overwrites the file it is handed. Written with
// printf so the exact trailing bytes are under the test's control.
let editor = repo.write_script(
"fake-editor.sh",
"#!/bin/sh\nprintf 'from the editor\\n\\n' > \"$1\"\n",
);
let output = repo.run_in_pty(&["issue", "comment", &id], &editor);
assert!(
output.status.success(),
"editor run failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let json = repo.run_ok(&["issue", "show", &id, "--json"]);
assert_eq!(json_field(&json, "/comments/0/body"), "from the editor\n\n");
}
/// A body typed into an editor is not a commit message: nothing may be
/// stripped from it. A leading `#` is a markdown heading, and must survive.
#[test]
fn the_editor_strips_nothing_not_even_leading_hashes() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("hash body");
let editor = repo.write_script(
"fake-editor.sh",
"#!/bin/sh\nprintf '# Heading\\n\\nbody text\\n' > \"$1\"\n",
);
repo.run_in_pty(&["issue", "comment", &id], &editor);
let json = repo.run_ok(&["issue", "show", &id, "--json"]);
assert_eq!(
json_field(&json, "/comments/0/body"),
"# Heading\n\nbody text\n"
);
}
/// Leaving the editor without writing anything aborts the whole command —
/// no empty comment is appended to the DAG, where it could never be removed.
#[test]
fn an_empty_editor_buffer_aborts_without_appending() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("aborted");
let editor = repo.write_script("fake-editor.sh", "#!/bin/sh\n: > \"$1\"\n");
let output = repo.run_in_pty(&["issue", "comment", &id], &editor);
assert!(
!output.status.success(),
"an empty editor buffer should abort the command"
);
let json = repo.run_ok(&["issue", "show", &id, "--json"]);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
value["comments"].as_array().unwrap().len(),
0,
"aborting must leave no comment behind"
);
}