a73x

tests/web_rendering_test.rs

Ref:   Size: 17.6 KiB   History

//! What the web UI actually puts on the page, driven through a live server.
//!
//! These cover three defects found by reading the rendered pages rather than
//! the templates — none of them is a malformed-HTML bug, so every existing
//! content assertion passed while all three were live:
//!
//! * `b2a57996` — timestamps printed as raw RFC3339 with nanoseconds, and a
//!   `Merged in` oid printed at 40 characters beside revisions printed at 8.
//! * `c43c459d` — an empty *filtered* list claiming the repository is empty.
//! * `850f3b8e` — a Branch column naming worktree branches that were deleted
//!   with the worktree.
//!
//! `--json` is asserted alongside each, because the fix is a *display* policy:
//! the HTML gets to be short, and the scripted surface must keep the full id
//! and the full timestamp it has always carried.

mod common;

use common::ServerHarness;
use serde_json::Value;
use std::process::Command;

/// A branch name of the shape agent worktrees produce and then delete.
const EPHEMERAL_BRANCH: &str = "worktree-agent-ab8e151c29539ab74";

/// The text a reader sees, with every tag — and so every attribute — removed.
///
/// The full timestamp is deliberately still in the page, inside `title=`, so a
/// substring search over the raw HTML cannot tell the fix from the defect. The
/// question is only ever what is rendered *between* the tags.
fn visible_text(body: &str) -> String {
    let mut text = String::with_capacity(body.len());
    let mut in_tag = false;
    for c in body.chars() {
        match c {
            '<' => in_tag = true,
            '>' => in_tag = false,
            _ if !in_tag => text.push(c),
            _ => {}
        }
    }
    text
}

/// Whether any visible text is an RFC3339 timestamp with a fractional second —
/// the 35-character shape issue `b2a57996` reported.
fn has_nanosecond_timestamp(body: &str) -> bool {
    visible_text(body).split('T').skip(1).any(|rest| {
        let head: String = rest.chars().take(16).collect();
        head.len() >= 10 && head.as_bytes()[2] == b':' && head.contains('.')
    })
}

fn patch_on_ephemeral_branch(harness: &ServerHarness, title: &str, branch: &str) -> String {
    let repo = harness.work_repo();
    repo.git(&["checkout", "-b", branch]);
    repo.commit_file(
        &format!("{branch}.rs"),
        "pub fn work() {}\n",
        &format!("work on {branch}"),
    );
    let out = repo.run_ok(&["patch", "create", "-t", title, "-B", branch]);
    repo.git(&["checkout", "main"]);
    out.trim()
        .strip_prefix("Created patch ")
        .expect("patch create prints the id")
        .to_string()
}

fn patch_json(harness: &ServerHarness, id: &str) -> Value {
    serde_json::from_str(&harness.work_repo().run_ok(&["patch", "show", id, "--json"])).unwrap()
}

fn issue_json(harness: &ServerHarness, id: &str) -> Value {
    serde_json::from_str(&harness.work_repo().run_ok(&["issue", "show", id, "--json"])).unwrap()
}

// ---------------------------------------------------------------------------
// b2a57996 — timestamps and the `Merged in` oid
// ---------------------------------------------------------------------------

#[test]
fn list_timestamps_are_short_with_the_full_value_on_hover() {
    let harness = ServerHarness::new("render-timestamps");
    let patch_id = patch_on_ephemeral_branch(&harness, "A patch with a timestamp", "feat/stamped");
    let issue_id = harness.work_repo().issue_open("An issue with a timestamp");
    harness.push_head();
    harness.push_collab_refs();

    let full_patch_stamp = patch_json(&harness, &patch_id)["last_updated"]
        .as_str()
        .unwrap()
        .to_string();
    let full_issue_stamp = issue_json(&harness, &issue_id)["last_updated"]
        .as_str()
        .unwrap()
        .to_string();

    for (path, full) in [
        (
            format!("/{}/patches", harness.repo_name()),
            &full_patch_stamp,
        ),
        (
            format!("/{}/issues", harness.repo_name()),
            &full_issue_stamp,
        ),
    ] {
        let body = harness.get_ok(&path).body;
        assert!(
            !has_nanosecond_timestamp(&body),
            "{path} still renders a nanosecond timestamp:\n{body}"
        );
        assert!(
            body.contains(&format!("title=\"{full}\"")),
            "{path} drops the full timestamp instead of keeping it on hover"
        );
        // The short rendering: date and minute, no seconds, no nanoseconds.
        let short = &full[..16].replace('T', " ");
        assert!(
            body.contains(&format!(">{short}<")),
            "{path} does not render the short timestamp '{short}'"
        );
    }
}

#[test]
fn patch_detail_timestamps_are_short_with_the_full_value_on_hover() {
    let harness = ServerHarness::new("render-detail-stamps");
    let patch_id = patch_on_ephemeral_branch(&harness, "Reviewed patch", "feat/reviewed");
    harness.work_repo().run_ok(&[
        "patch", "review", &patch_id, "-v", "approve", "-b", "Looks ok",
    ]);
    harness
        .work_repo()
        .run_ok(&["patch", "comment", &patch_id, "-b", "A thread comment"]);
    harness.push_head();
    harness.push_collab_refs();

    let body = harness
        .get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id))
        .body;
    assert!(
        !has_nanosecond_timestamp(&body),
        "the patch detail page still renders nanosecond timestamps:\n{body}"
    );

    let json = patch_json(&harness, &patch_id);
    let revision_stamp = json["revisions"][0]["timestamp"].as_str().unwrap();
    assert!(
        body.contains(&format!("title=\"{revision_stamp}\"")),
        "the revision timestamp is not available on hover"
    );
}

#[test]
fn merged_in_is_abbreviated_like_the_revision_commits_beside_it() {
    let harness = ServerHarness::new("render-merged-in");
    let patch_id = patch_on_ephemeral_branch(&harness, "A landed patch", "feat/landed");
    let repo = harness.work_repo();
    repo.git(&["merge", "--ff-only", "feat/landed"]);
    repo.run_ok(&["patch", "merge", &patch_id]);
    harness.push_head();
    harness.push_collab_refs();

    let merge_commit = patch_json(&harness, &patch_id)["merge_commit"]
        .as_str()
        .unwrap()
        .to_string();
    assert_eq!(merge_commit.len(), 40, "the event records the full oid");

    let body = harness
        .get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id))
        .body;

    // The link still goes to the full oid; only the text is abbreviated.
    assert!(
        body.contains(&format!("/diff/{merge_commit}")),
        "the merge link no longer resolves the full commit"
    );
    assert!(
        body.contains(&format!(">{}<", &merge_commit[..8])),
        "'Merged in' does not render the 8-character oid the revisions use"
    );
    assert!(
        !body.contains(&format!(">{merge_commit}<")),
        "'Merged in' still renders the full 40-character oid"
    );
}

// ---------------------------------------------------------------------------
// c43c459d — an empty filtered list must not claim the repository is empty
// ---------------------------------------------------------------------------

#[test]
fn an_empty_open_list_names_the_filter_and_offers_the_way_out() {
    let harness = ServerHarness::new("render-empty-filtered");
    let patch_id = patch_on_ephemeral_branch(&harness, "A closed patch", "feat/closed");
    harness.work_repo().patch_close(&patch_id);
    let issue_id = harness.work_repo().issue_open("A closed issue");
    harness.work_repo().issue_close(&issue_id);
    harness.push_head();
    harness.push_collab_refs();

    let patches = harness
        .get_ok(&format!("/{}/patches", harness.repo_name()))
        .body;
    assert!(
        !patches.contains("No patches."),
        "the patch list still claims the repository has no patches"
    );
    assert!(
        patches.contains("No open patches"),
        "the patch list does not say which filter was empty:\n{patches}"
    );
    assert!(
        patches.contains("1 total"),
        "the patch list does not say how many patches there are:\n{patches}"
    );
    assert!(
        patches.contains("patches?filter=all"),
        "the patch list offers no way to see the rest"
    );

    let issues = harness
        .get_ok(&format!("/{}/issues", harness.repo_name()))
        .body;
    assert!(
        !issues.contains("No issues."),
        "the issue list still claims the repository has no issues"
    );
    assert!(
        issues.contains("No open issues") && issues.contains("1 total"),
        "the issue list does not say which filter was empty:\n{issues}"
    );
    assert!(
        issues.contains("issues?filter=all"),
        "the issue list offers no way to see the rest"
    );
}

#[test]
fn a_genuinely_empty_repository_still_says_so_plainly() {
    let harness = ServerHarness::new("render-empty-repo");
    harness.push_head();

    let patches = harness
        .get_ok(&format!("/{}/patches", harness.repo_name()))
        .body;
    assert!(
        patches.contains("No patches yet."),
        "an empty repository should say so without offering a filter:\n{patches}"
    );
    assert!(
        !patches.contains("total"),
        "an empty repository should not offer to show zero more patches"
    );

    let issues = harness
        .get_ok(&format!("/{}/issues", harness.repo_name()))
        .body;
    assert!(
        issues.contains("No issues yet."),
        "an empty repository should say so without offering a filter:\n{issues}"
    );
}

// ---------------------------------------------------------------------------
// 850f3b8e — the Branch column
// ---------------------------------------------------------------------------

#[test]
fn the_patch_list_shows_the_base_ref_not_the_ephemeral_branch() {
    let harness = ServerHarness::new("render-branch-column");
    patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);
    harness.push_head();
    harness.push_collab_refs();

    let body = harness
        .get_ok(&format!("/{}/patches?filter=all", harness.repo_name()))
        .body;
    assert!(
        !body.contains(EPHEMERAL_BRANCH),
        "the patch list still names a branch that no longer exists:\n{body}"
    );
    assert!(
        body.contains("<th>Base</th>"),
        "the patch list has no Base column:\n{body}"
    );
    assert!(
        body.contains(">main<"),
        "the patch list does not say what the patch targets:\n{body}"
    );
}

/// Provenance still belongs somewhere — `patch show` is where, on both
/// surfaces. Dropping it from the list is not dropping it from the tool.
#[test]
fn the_patch_detail_page_still_records_the_branch_it_came_from() {
    let harness = ServerHarness::new("render-branch-detail");
    let patch_id = patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);
    harness.push_head();
    harness.push_collab_refs();

    let body = harness
        .get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id))
        .body;
    assert!(
        body.contains(EPHEMERAL_BRANCH),
        "the detail page lost the branch the patch came from"
    );
}

#[test]
fn the_cli_patch_list_agrees_with_the_web_list_about_the_base_ref() {
    let harness = ServerHarness::new("render-cli-agreement");
    patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);

    let listing = harness.work_repo().run_ok(&["patch", "list"]);
    assert!(
        !listing.contains(EPHEMERAL_BRANCH),
        "`patch list` names a branch that no longer exists:\n{listing}"
    );
    assert!(
        listing.contains("main"),
        "`patch list` does not say what the patch targets:\n{listing}"
    );
}

// ---------------------------------------------------------------------------
// The scripted surface is untouched by all of the above
// ---------------------------------------------------------------------------

#[test]
fn json_keeps_full_ids_timestamps_and_the_branch_the_html_no_longer_shows() {
    let harness = ServerHarness::new("render-json-intact");
    let patch_id = patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);

    let listing: Value =
        serde_json::from_str(&harness.work_repo().run_ok(&["patch", "list", "--json"])).unwrap();
    let entry = &listing.as_array().unwrap()[0];

    assert_eq!(
        entry["id"].as_str().unwrap().len(),
        40,
        "--json abbreviated an id"
    );
    assert_eq!(
        entry["branch"].as_str().unwrap(),
        EPHEMERAL_BRANCH,
        "--json dropped the branch, which is provenance a script may still want"
    );
    let stamp = entry["last_updated"].as_str().unwrap();
    assert!(
        stamp.contains('.') && stamp.len() > 25,
        "--json truncated a timestamp to the display format: {stamp}"
    );

    let detail = patch_json(&harness, &patch_id);
    assert_eq!(detail["id"].as_str().unwrap().len(), 40);
    assert!(detail["revisions"][0]["timestamp"]
        .as_str()
        .unwrap()
        .contains('.'));
}

// ---------------------------------------------------------------------------
// Rendering a page is a read
// ---------------------------------------------------------------------------

/// Every ref in the served repository, as `<name> <oid>` lines.
fn ref_snapshot(harness: &ServerHarness) -> String {
    let bare = harness
        .repos_dir()
        .join(format!("{}.git", harness.repo_name()));
    let output = Command::new("git")
        .args(["for-each-ref", "--format=%(refname) %(objectname)"])
        .current_dir(&bare)
        .output()
        .expect("failed to list refs");
    assert!(output.status.success());
    String::from_utf8(output.stdout).unwrap()
}

#[test]
fn rendering_pages_moves_no_refs_and_appends_no_events() {
    let harness = ServerHarness::new("render-read-only");
    let patch_id = patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);
    let issue_id = harness.work_repo().issue_open("An issue to read");
    harness.push_head();
    harness.push_collab_refs();

    let before = ref_snapshot(&harness);
    let name = harness.repo_name();
    for path in [
        format!("/{name}"),
        format!("/{name}/patches"),
        format!("/{name}/patches?filter=all"),
        format!("/{name}/patches/{patch_id}"),
        format!("/{name}/issues"),
        format!("/{name}/issues?filter=all"),
        format!("/{name}/issues/{issue_id}"),
        format!("/{name}/commits"),
        format!("/{name}/releases"),
    ] {
        harness.get_ok(&path);
    }
    let after = ref_snapshot(&harness);

    assert_eq!(
        before, after,
        "rendering pages changed the repository's refs"
    );
}

/// A live claim belongs on the page: a claim nobody can see is a claim people
/// work around. Both the list and the detail view show the holder, and an
/// expired one shows nobody — `refs/collab/*` says nothing about either, so
/// this is the only place the lease store and the UI meet.
#[test]
fn a_live_claim_shows_on_the_issue_pages_and_an_expired_one_does_not() {
    let harness = ServerHarness::new("render-claims");
    harness.push_head();
    let (_ref_name, full_id) = common::open_issue(
        &harness.work_repo_git2(),
        &common::alice(),
        "An issue to claim",
    );
    harness.push_collab_refs();
    let name = harness.repo_name().to_string();

    let unclaimed_list = harness.get_ok(&format!("/{name}/issues"));
    assert!(
        unclaimed_list.body.contains("Claimed by"),
        "the column should exist even with no claims"
    );

    let acquired = harness.ssh_exec(&format!(
        "collab-lease acquire '{name}.git' '{full_id}' --ttl 600"
    ));
    assert!(acquired.status.success());
    let holder =
        serde_json::from_str::<serde_json::Value>(String::from_utf8_lossy(&acquired.stdout).trim())
            .unwrap()["holder"]
            .as_str()
            .unwrap()
            .to_string();

    let list = harness.get_ok(&format!("/{name}/issues"));
    assert!(
        list.body.contains(&holder),
        "the list should name the holder {holder}: {}",
        list.body
    );

    let detail = harness.get_ok(&format!("/{name}/issues/{full_id}"));
    assert!(
        detail.body.contains("Claimed by:") && detail.body.contains(&holder),
        "the detail page should name the holder: {}",
        detail.body
    );
    assert!(
        detail.body.contains("expires"),
        "a ttl claim should say when it lapses: {}",
        detail.body
    );

    // A 1-second lease, waited out: the row survives (it carries the fencing
    // token) but the page must stop showing a claim.
    harness.ssh_exec(&format!("collab-lease release '{name}.git' '{full_id}'"));
    harness.ssh_exec(&format!(
        "collab-lease acquire '{name}.git' '{full_id}' --ttl 1"
    ));
    std::thread::sleep(std::time::Duration::from_millis(1200));

    let expired = harness.get_ok(&format!("/{name}/issues/{full_id}"));
    assert!(
        !expired.body.contains("Claimed by:"),
        "an expired claim must not be shown: {}",
        expired.body
    );
}

/// An open-ended claim (no `--ttl`) is an assignment, so it must not claim to
/// expire.
#[test]
fn an_open_ended_claim_shows_no_expiry() {
    let harness = ServerHarness::new("render-assigned");
    harness.push_head();
    let (_ref_name, full_id) = common::open_issue(
        &harness.work_repo_git2(),
        &common::alice(),
        "An assigned issue",
    );
    harness.push_collab_refs();
    let name = harness.repo_name().to_string();

    harness.ssh_exec(&format!("collab-lease acquire '{name}.git' '{full_id}'"));

    let detail = harness.get_ok(&format!("/{name}/issues/{full_id}"));
    assert!(detail.body.contains("Claimed by:"));
    assert!(
        !detail.body.contains("expires"),
        "an open-ended claim must not print an expiry: {}",
        detail.body
    );
}