a73x

tests/web_layout_test.rs

Ref:   Size: 8.9 KiB   History

//! The web UI's pages must not scroll sideways at a narrow viewport.
//!
//! This is a *layout* property, not a markup one: the HTML that overflows is
//! perfectly well-formed, every content assertion about it passes, and the
//! defect only exists once a browser has laid the page out at a width. So the
//! primary test here renders real server output in a real headless browser and
//! compares `documentElement.scrollWidth` with `window.innerWidth` — the same
//! measurement a human made by hand when this was filed.
//!
//! The browser is an *external oracle*: it is not part of this project and has
//! no idea what the test expects, so it cannot be talked into agreeing. A test
//! that only asserted "the template contains a wrapper div" would be blessing
//! our own guess about what makes a page fit.
//!
//! The page is measured from a `file://` copy rather than over HTTP because the
//! server's pages are entirely self-contained — the stylesheet is inlined in
//! `base.html` and nothing else is fetched — so the two lay out identically,
//! and the copy avoids handing a browser a URL to a live test server.
//!
//! If no browser is installed the measurement is skipped loudly, and
//! `structural_fallback` below still guards the invariant that produces the
//! property. Set `GIT_COLLAB_REQUIRE_BROWSER=1` to turn the skip into a
//! failure, which is what CI should do once a browser is provisioned.

mod common;

use common::ServerHarness;
use std::path::PathBuf;
use std::process::Command;

/// Viewport width to measure at. Narrow enough to be a real phone and to be
/// the width the defect was reported at.
const NARROW_WIDTH: u32 = 500;

/// A branch name of the shape agent worktrees actually produce, which is what
/// made the patch list overflow in the first place.
const EPHEMERAL_BRANCH: &str = "worktree-agent-ab8e151c29539ab74";

/// Locate a Chromium-family browser, or `None` if this machine has none.
fn find_browser() -> Option<PathBuf> {
    if let Ok(explicit) = std::env::var("CHROME") {
        let path = PathBuf::from(explicit);
        if path.exists() {
            return Some(path);
        }
    }
    for name in [
        "chromium",
        "chromium-browser",
        "google-chrome-stable",
        "google-chrome",
        "chrome",
    ] {
        if let Ok(output) = Command::new("which").arg(name).output() {
            if output.status.success() {
                let found = String::from_utf8_lossy(&output.stdout).trim().to_string();
                if !found.is_empty() {
                    return Some(PathBuf::from(found));
                }
            }
        }
    }
    None
}

/// Lay `html` out at `width` pixels and report
/// `(documentElement.scrollWidth, window.innerWidth)`.
///
/// The probe script appends its measurement to the DOM and `--dump-dom` prints
/// the DOM after load, which is how a number gets back out of a browser that
/// has no other channel to talk to us.
fn measure(browser: &PathBuf, html: &str, width: u32) -> (u32, u32) {
    let dir = tempfile::TempDir::new().unwrap();
    let page = dir.path().join("page.html");
    // The result is keyed on an *attribute* rather than a text marker: the
    // dumped DOM includes this script's own source, so any literal the script
    // prints would be found in the script before it was found in the result.
    let probe = "\n<script>window.addEventListener('load',function(){\
        var d=document.createElement('div');\
        d.setAttribute('data-measure','');\
        d.textContent=document.documentElement.scrollWidth+' '+window.innerWidth;\
        document.body.appendChild(d);});</script>\n";
    std::fs::write(&page, format!("{html}{probe}")).unwrap();

    let profile = dir.path().join("profile");
    let output = Command::new(browser)
        .args([
            "--headless",
            "--disable-gpu",
            "--no-sandbox",
            "--no-first-run",
            "--disable-extensions",
            "--dump-dom",
            "--virtual-time-budget=4000",
        ])
        .arg(format!("--window-size={width},900"))
        .arg(format!("--user-data-dir={}", profile.display()))
        .arg(format!("file://{}", page.display()))
        .output()
        .expect("failed to run headless browser");

    let dom = String::from_utf8_lossy(&output.stdout);
    let after = dom
        .split("data-measure=\"\">")
        .nth(1)
        .unwrap_or_else(|| panic!("browser produced no measurement; DOM was:\n{dom}"));
    let text = after
        .split('<')
        .next()
        .expect("measurement div has a closing tag");
    let mut parts = text.split_whitespace();
    let mut next = || {
        parts
            .next()
            .unwrap_or_else(|| panic!("malformed measurement '{text}'"))
            .parse::<u32>()
            .unwrap_or_else(|_| panic!("malformed measurement '{text}'"))
    };
    let scroll_width = next();
    let inner_width = next();
    (scroll_width, inner_width)
}

/// Seed a repository whose lists are as wide as the real ones: a patch created
/// from an ephemeral worktree branch, an issue, a commit and a release.
fn seed(harness: &ServerHarness) {
    let repo = harness.work_repo();
    repo.commit_file(
        "src/rendering.rs",
        "pub fn render() {}\n",
        "Render the patch list without forcing the page sideways",
    );
    repo.git(&["checkout", "-b", EPHEMERAL_BRANCH]);
    repo.commit_file(
        "src/wide.rs",
        "pub fn wide() {}\n",
        "commit on an agent worktree branch",
    );
    repo.run_ok(&[
        "patch",
        "create",
        "-t",
        "Wrap list tables so a narrow viewport does not scroll sideways",
        "-B",
        EPHEMERAL_BRANCH,
    ]);
    repo.git(&["checkout", "main"]);
    repo.issue_open("Timestamps render as full RFC3339 with nanoseconds throughout");

    harness.push_head();
    harness.push_collab_refs();

    // A release too: its listing carries a 64-character sha256, which is wider
    // than the viewport all by itself.
    let content: Vec<u8> = (0u32..64).flat_map(|i| i.to_le_bytes()).collect();
    harness.ssh_exec_with_stdin(
        &format!(
            "collab-release upload '{}.git' 'v1.0.0' 'git-collab-x86_64-unknown-linux-gnu.tar.gz'",
            harness.repo_name()
        ),
        &content,
    );
}

/// Every page a reader lands on, at the width a phone gives them.
fn pages(repo_name: &str) -> Vec<String> {
    vec![
        format!("/{repo_name}"),
        format!("/{repo_name}/patches"),
        format!("/{repo_name}/patches?filter=all"),
        format!("/{repo_name}/issues"),
        format!("/{repo_name}/issues?filter=all"),
        format!("/{repo_name}/commits"),
        format!("/{repo_name}/releases"),
        format!("/{repo_name}/tree"),
    ]
}

#[test]
fn no_page_scrolls_sideways_at_a_narrow_viewport() {
    let browser = match find_browser() {
        Some(b) => b,
        None => {
            let message = "SKIPPED no_page_scrolls_sideways_at_a_narrow_viewport: no \
                 Chromium-family browser found. Install chromium or set $CHROME. \
                 Set GIT_COLLAB_REQUIRE_BROWSER=1 to make this a failure.";
            if std::env::var("GIT_COLLAB_REQUIRE_BROWSER").is_ok() {
                panic!("{message}");
            }
            eprintln!("{message}");
            return;
        }
    };

    let harness = ServerHarness::new("layout-narrow");
    seed(&harness);

    for path in pages(harness.repo_name()) {
        let page = harness.get_ok(&path);
        let (scroll_width, inner_width) = measure(&browser, &page.body, NARROW_WIDTH);
        assert!(
            scroll_width <= inner_width,
            "{path} scrolls the page sideways at {NARROW_WIDTH}px: \
             documentElement.scrollWidth = {scroll_width}, window.innerWidth = {inner_width}"
        );
    }
}

/// The invariant that produces the property above, asserted without a browser
/// so the guard survives on a machine that has none.
///
/// Deliberately weaker than the measurement: it says every table is inside
/// something that can scroll on its own, which is *how* the pages fit, not
/// *that* they fit. If these two ever disagree, believe the browser.
#[test]
fn every_table_sits_inside_a_horizontal_scroll_container() {
    let harness = ServerHarness::new("layout-structure");
    seed(&harness);

    let base_css = harness.get_ok(&format!("/{}/patches", harness.repo_name()));
    assert!(
        base_css.body.contains(".table-scroll") && base_css.body.contains("overflow-x: auto"),
        "the stylesheet defines no scrollable table container"
    );

    for path in pages(harness.repo_name()) {
        let page = harness.get_ok(&path);
        for (index, _) in page.body.match_indices("<table") {
            let before = &page.body[..index];
            let wrapper = before.rfind("table-scroll");
            let closing = before.rfind("</div>");
            assert!(
                wrapper.is_some() && wrapper > closing,
                "{path} has a <table> that is not inside a .table-scroll container"
            );
        }
    }
}