a73x

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"
    );
}