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