#[path = "web_render/cdp.rs"]
mod cdp;
#[allow(unused_macros, unused_imports)]
mod common;
use std::collections::BTreeSet;
use std::net::Ipv4Addr;
use std::time::Duration;
use magi::queue::{Queue, Source, Task, TaskStatus};
use magi::run::{RunState, RunStatus};
use magi::talk::{self, Talks};
use serde_json::Value;
struct Case {
name: &'static str,
route: &'static str,
pane: &'static str,
rows: &'static str,
heading: &'static str,
link: fn(&str) -> String,
expect: usize,
}
const CASES: [Case; 3] = [
Case {
name: "queue",
route: "#/queue",
pane: "#view-queue",
rows: "#queue-sections li.card:not(.skeleton)",
heading: "queue-h",
link: |id| format!("a[href=\"#/queue/{id}\"]"),
expect: 5,
},
Case {
name: "runs",
route: "#/runs",
pane: ".runs-main",
rows: "#view-runs a.card.run-card",
heading: "runs-h",
link: |id| format!("a[href=\"#/runs/{id}\"]"),
expect: 1,
},
Case {
name: "chat",
route: "#/chat",
pane: "#view-talks",
rows: "#talks-list a.card",
heading: "talks-h",
link: |id| format!("a[href=\"#/chat/{id}\"]"),
expect: 3,
},
];
const VIEWPORTS: [(u32, u32, bool); 3] =
[(1280, 900, false), (1950, 1000, false), (390, 844, true)];
const KNOWN_FAILURES: &[(&str, &str, &str)] = &[];
const MEASURE: &str = r##"(() => {
const ROWS = __ROWS__, PANE = __PANE__, HEAD = __HEAD__;
const fails = [];
const shown = (e) => {
const r = e.getBoundingClientRect(), cs = getComputedStyle(e);
return r.width > 0 && r.height > 0 && cs.display !== "none" && cs.visibility !== "hidden";
};
const hit = (a, b) =>
a.left < b.right - 1 && b.left < a.right - 1 && a.top < b.bottom - 1 && b.top < a.bottom - 1;
const pane = document.querySelector(PANE);
if (!pane) fails.push(["pane-missing", PANE]);
const pr = pane ? pane.getBoundingClientRect() : null;
const pr0 = pr;
if (pane && pane.scrollWidth > pane.clientWidth + 1)
fails.push(["pane-overflow", `${[...pane.querySelectorAll("*")].filter((e) => e.getBoundingClientRect().right > pr0.right + 1).slice(0, 3).map((e) => e.tagName + "." + e.className + "#" + e.id).join(" | ")} ${PANE} scrollWidth ${pane.scrollWidth} > clientWidth ${pane.clientWidth}`]);
let rows = 0;
for (const row of document.querySelectorAll(ROWS)) {
if (!shown(row)) continue; // a collapsed section's rows have no layout
rows++;
const rr = row.getBoundingClientRect();
const link = row.querySelector("a[href]");
const label = (row.getAttribute("href") || (link && link.getAttribute("href")) || "row").toString();
if (pr && (rr.left < pr.left - 1 || rr.right > pr.right + 1))
fails.push(["row-overflow", `${label}: ${rr.left}..${rr.right} outside ${pr.left}..${pr.right}`]);
const title = row.querySelector(".card-title");
if (!title || !shown(title) || !(title.innerText || "").trim()) {
fails.push(["title-visible", `${label}: no visible title text`]);
continue;
}
const tr = title.getBoundingClientRect();
// Line 1 of a row is the title on the full row width, never a sliver.
if (tr.width < rr.width * 0.5)
fails.push(["title-narrow", `${label}: title ${Math.round(tr.width)}px of a ${Math.round(rr.width)}px row`]);
for (const other of row.querySelectorAll(".card-top > *, .card-meta > *")) {
if (other.contains(title) || title.contains(other) || !shown(other)) continue;
if (hit(tr, other.getBoundingClientRect()))
fails.push(["title-overlap", `${label}: title overlaps ${other.className || other.tagName}`]);
}
}
window.scrollTo(0, 0);
const h = document.getElementById(HEAD);
if (!h || !shown(h)) fails.push(["heading-covered", `${HEAD} is not visible`]);
else {
const r = h.getBoundingClientRect();
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (!top || !h.contains(top))
fails.push(["heading-covered", `${HEAD} is covered by ${top ? (top.id || top.className || top.tagName) : "nothing"}`]);
}
return { rows, failures: fails };
})()"##;
fn console_problems(events: &[Value]) -> Vec<String> {
let mut out = Vec::new();
for e in events {
let p = &e["params"];
match e["method"].as_str().unwrap_or("") {
"Runtime.exceptionThrown" => out.push(format!(
"exception: {}",
p["exceptionDetails"]["exception"]["description"]
.as_str()
.or(p["exceptionDetails"]["text"].as_str())
.unwrap_or("?")
)),
"Runtime.consoleAPICalled" if p["type"] == "error" => {
out.push(format!("console.error: {}", p["args"]));
}
"Log.entryAdded" if p["entry"]["level"] == "error" => {
let entry = &p["entry"];
let text = entry["text"].as_str().unwrap_or("");
let url = entry["url"].as_str().unwrap_or("");
if url.contains("favicon") || text.contains("favicon") {
continue;
}
out.push(format!(
"log[{}]: {text} {url}",
entry["source"].as_str().unwrap_or("?")
));
}
_ => {}
}
}
out
}
fn known(page: &str, check: &str) -> Option<&'static str> {
KNOWN_FAILURES
.iter()
.find(|(p, c, _)| (*p == page || *p == "*") && *c == check)
.map(|(_, _, why)| *why)
}
async fn serve(
home: &std::path::Path,
queue: Queue,
talks: Talks,
runs_root: std::path::PathBuf,
repo: &std::path::Path,
) -> String {
let worktrees = home.join("wt").join("magi");
std::fs::create_dir_all(&worktrees).unwrap();
let ui = magi::web::Ui::new(
queue,
magi::ask::Questions::at(home.join("questions")),
talks,
runs_root,
home.to_path_buf(),
repo.to_path_buf(),
)
.with_worktrees_root(worktrees);
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.expect("bind loopback");
let base = format!("http://{}/", listener.local_addr().unwrap());
tokio::spawn(async move {
let _ = axum::serve(listener, ui.router()).await;
});
base
}
fn seed_tasks(queue: &Queue, repo: &std::path::Path, run_id: &str) -> Vec<String> {
let mut ids = Vec::new();
let long =
"A task whose title is long enough to wrap onto several lines on a narrow phone screen";
for (title, status) in [
("Running task", TaskStatus::Running),
("Queued task", TaskStatus::Queued),
("Held task", TaskStatus::Held),
("Failed task", TaskStatus::Failed),
(long, TaskStatus::Queued),
("Blocked task", TaskStatus::Blocked),
("Done task", TaskStatus::Done),
("Solo task from a chat", TaskStatus::Queued),
("Task that was tried twice", TaskStatus::Running),
("", TaskStatus::Queued),
] {
let source = if title.starts_with("Solo") {
Source::Agent {
run: "7dd7aaaa".to_owned(),
node: magi::queue::CHAT_NODE.to_owned(),
}
} else {
Source::Human
};
let mut t = Task::new(
title.to_owned(),
format!("Instruction for: {title}"),
repo.to_path_buf(),
source,
);
t.status = status;
t.solo = title.starts_with("Solo");
if title.contains("twice") {
t.attempts = 2;
}
match status {
TaskStatus::Running => t.runs = vec![run_id.to_owned()],
TaskStatus::Held => t.hold_reason = Some("held by hand".to_owned()),
TaskStatus::Failed => t.last_error = Some("the gate failed".to_owned()),
_ => {}
}
queue.put(&mut t).expect("seed task");
ids.push(t.id.clone());
}
ids
}
#[tokio::test]
async fn pages_render_with_visible_titles_and_no_console_errors() {
let Some(chrome) = cdp::find_chrome() else {
assert!(
std::env::var_os("CI").is_none(),
"CI is set but no Chrome/Chromium was found (set MAGI_CHROME)"
);
eprintln!("SKIP web_render: no Chrome/Chromium found (set MAGI_CHROME to run it)");
return;
};
let guard = common::home_lock().await;
let fx = common::fixture(guard, common::Judges::Unanimous, false);
let home = fx.tmp.path().join("magi-home");
let queue = Queue::at(home.join("queue"));
let talks = Talks::at(home.join("talks"));
let runs_root = home.join("runs");
let mut run_ids = Vec::new();
for (id, status, text) in [
(
"20260901-000001-aa01",
RunStatus::Reviewing,
"Add a retry budget to the fetch loop",
),
(
"20260901-000002-aa02",
RunStatus::Blocked,
"Rework the settings screen so that every field carries a long and \
rather descriptive instruction title that has to wrap on a phone",
),
] {
let mut run = RunState::new(
fx.repo.clone(),
"main".to_owned(),
"0000000".to_owned(),
text.to_owned(),
fx.config.clone(),
);
run.id = id.to_owned();
run.status = status;
run.save_under(&home).expect("seed run");
run_ids.push(id.to_owned());
}
let task_ids = seed_tasks(&queue, &fx.repo, &run_ids[0]);
let agent = fx.config.agents[0].id.clone();
let mut talk_ids = Vec::new();
for text in [
"How does the queue pick the next task?",
"A much longer opening question that keeps going so the conversation \
title has to be clipped or wrapped rather than pushing the row wide",
"Short one",
] {
let mut t =
talk::begin(&talks, &fx.config, fx.repo.clone(), Some(&agent)).expect("seed talk");
talk::record(&mut t, &talks, text, Vec::new()).expect("seed turn");
talk_ids.push(t.id.clone());
}
let base = serve(&home, queue, talks, runs_root, &fx.repo).await;
let mut browser = cdp::Browser::launch(&chrome)
.await
.unwrap_or_else(|e| panic!("could not start Chrome at {}: {e}", chrome.display()));
let mut failures: Vec<String> = Vec::new();
let mut hits: BTreeSet<(String, String)> = BTreeSet::new();
for case in &CASES {
let ids = match case.name {
"queue" => &task_ids,
"runs" => &run_ids,
_ => &talk_ids,
};
for &(w, h, mobile) in &VIEWPORTS {
let tag = format!("{} @{w}px", case.name);
let url = format!("{base}{}", case.route);
let page = browser
.open_page(&url, w, h, mobile)
.await
.unwrap_or_else(|e| panic!("{tag}: open: {e}"));
let all = ids
.iter()
.take(case.expect)
.map(|id| format!("!!document.querySelector('{}')", (case.link)(id)))
.collect::<Vec<_>>()
.join(" && ");
if let Err(e) = browser.wait_for(&page, &all, Duration::from_secs(30)).await {
let seen = browser
.eval(
&page,
"document.querySelector('main').innerText.slice(0, 600)",
)
.await
.unwrap_or_default();
let console = console_problems(&browser.take_events(&page.session));
panic!("{tag}: {e}\npage text: {seen}\nconsole: {console:?}");
}
browser
.eval(
&page,
"document.fonts.ready.then(() => new Promise(r => \
requestAnimationFrame(() => requestAnimationFrame(() => r(true)))))",
)
.await
.unwrap_or_else(|e| panic!("{tag}: settle: {e}"));
browser
.eval(
&page,
"document.querySelectorAll('details.list-section').forEach((d) => { d.open = true; })",
)
.await
.unwrap_or_else(|e| panic!("{tag}: expand: {e}"));
browser.settle(Duration::from_millis(300)).await.unwrap();
let script = MEASURE
.replace("__ROWS__", &Value::from(case.rows).to_string())
.replace("__PANE__", &Value::from(case.pane).to_string())
.replace("__HEAD__", &Value::from(case.heading).to_string());
let out = browser
.eval(&page, &script)
.await
.unwrap_or_else(|e| panic!("{tag}: measure: {e}"));
let rows = out["rows"].as_u64().unwrap_or(0);
if (rows as usize) < case.expect {
failures.push(format!(
"{tag}: {rows} visible row(s) rendered, expected {}",
case.expect
));
}
for f in out["failures"].as_array().into_iter().flatten() {
let check = f[0].as_str().unwrap_or("?");
let detail = f[1].as_str().unwrap_or("");
match known(case.name, check) {
Some(why) => {
eprintln!("KNOWN FAILURE [{tag}] {check} ({why}): {detail}");
hits.insert((case.name.to_owned(), check.to_owned()));
}
None => failures.push(format!("{tag}: {check}: {detail}")),
}
}
browser.settle(Duration::from_millis(200)).await.unwrap();
for p in console_problems(&browser.take_events(&page.session)) {
failures.push(format!("{tag}: {p}"));
}
browser.close_page(&page).await;
}
}
for (p, c, why) in KNOWN_FAILURES {
if !hits.contains(&((*p).to_owned(), (*c).to_owned())) {
eprintln!(
"NOTE: known failure ({p}, {c}) [{why}] did not fire; \
remove it from KNOWN_FAILURES in tests/web_render.rs"
);
}
}
assert!(
failures.is_empty(),
"web UI rendering regressions:\n {}",
failures.join("\n ")
);
}
#[tokio::test]
async fn runs_search_counts_extra_rows_as_shown_and_never_parses_hit_text() {
let Some(chrome) = cdp::find_chrome() else {
assert!(
std::env::var_os("CI").is_none(),
"CI is set but no Chrome/Chromium was found (set MAGI_CHROME)"
);
eprintln!("SKIP web_render: no Chrome/Chromium found (set MAGI_CHROME to run it)");
return;
};
let guard = common::home_lock().await;
let fx = common::fixture(guard, common::Judges::Unanimous, false);
let home = fx.tmp.path().join("magi-home");
let queue = Queue::at(home.join("queue"));
let talks = Talks::at(home.join("talks"));
const WINDOW: usize = 50;
let markup = "<img src=x id=pwn>";
for n in 1..=WINDOW + 1 {
let text = match n {
1 => format!("zebrafruit outside the window {markup}"),
n if n == WINDOW + 1 => "zebrafruit inside the window".to_owned(),
n => format!("ordinary run {n}"),
};
let mut run = RunState::new(
fx.repo.clone(),
"main".to_owned(),
"0000000".to_owned(),
text,
fx.config.clone(),
);
run.id = format!("20260901-{n:06}-bb{n:02}");
run.status = RunStatus::Reviewing;
run.save_under(&home).expect("seed run");
}
let base = serve(&home, queue, talks, home.join("runs"), &fx.repo).await;
let mut browser = cdp::Browser::launch(&chrome)
.await
.unwrap_or_else(|e| panic!("could not start Chrome at {}: {e}", chrome.display()));
let page = browser
.open_page(&format!("{base}#/runs"), 1280, 900, false)
.await
.expect("open");
let w = Duration::from_secs(30);
browser
.wait_for(
&page,
"(() => { const b = document.getElementById('runs-search'); \
return !!b && !b.hidden; })()",
w,
)
.await
.expect("search box");
browser
.eval(
&page,
"(() => { const i = document.getElementById('runs-search-input'); \
i.value = 'zebrafruit'; i.dispatchEvent(new Event('input', { bubbles: true })); \
return true; })()",
)
.await
.expect("type");
browser
.wait_for(
&page,
"(document.getElementById('runs-search-status').textContent || '').includes('match')",
w,
)
.await
.expect("search answered");
let out = browser
.eval(
&page,
"({ status: document.getElementById('runs-search-status').textContent, \
extra: document.querySelectorAll('#runs-search-extra li').length, \
pwn: document.querySelectorAll('#pwn').length, \
snippet: [...document.querySelectorAll('.card-snippet')].map((e) => e.textContent).join('\\n') })",
)
.await
.expect("read");
let status = out["status"].as_str().unwrap_or_default();
assert!(status.starts_with("2 runs match"), "status: {status}");
assert!(
!status.contains("hidden"),
"extra rows counted as hidden: {status}"
);
assert_eq!(
out["extra"], 1,
"the out-of-window hit is one extra row: {out}"
);
assert_eq!(out["pwn"], 0, "hit text was parsed as markup: {out}");
assert!(
out["snippet"].as_str().unwrap_or_default().contains(markup),
"snippet should show the markup literally: {out}"
);
browser.close_page(&page).await;
}
#[tokio::test]
async fn run_detail_tabs_landing_and_strip_hold_at_both_widths_and_themes() {
let Some(chrome) = cdp::find_chrome() else {
assert!(
std::env::var_os("CI").is_none(),
"CI is set but no Chrome/Chromium was found (set MAGI_CHROME)"
);
eprintln!("SKIP web_render: no Chrome/Chromium found (set MAGI_CHROME to run it)");
return;
};
let guard = common::home_lock().await;
let fx = common::fixture(guard, common::Judges::Unanimous, false);
let home = fx.tmp.path().join("magi-home");
let queue = Queue::at(home.join("queue"));
let talks = Talks::at(home.join("talks"));
let id = "20260901-000009-cc09";
let mut run = RunState::new(
fx.repo.clone(),
"main".to_owned(),
"0000000".to_owned(),
"Merged run with no fix round and no follow-up".to_owned(),
fx.config.clone(),
);
run.id = id.to_owned();
run.status = RunStatus::Merged;
run.pr = Some(magi::run::PrRecord {
url: "https://github.com/example/repo/pull/7".to_owned(),
number: 7,
state: "merged".to_owned(),
checks: "green".to_owned(),
round: 0,
rounds: 3,
red_at_merge: Vec::new(),
});
run.event("implement", "candidate A started");
run.event("implement", "candidate B started");
run.event("judge", "ranking in");
run.advice = Some(magi::advise::Advice {
records: (1..=3)
.map(|n| {
magi::advise::AdvisorRecord::failed(n, format!("agent-{n}"), "no proposal".into())
})
.collect(),
synthesis: Some("A blended brief.".to_owned()),
});
run.save_under(&home).expect("seed run");
let base = serve(&home, queue, talks, home.join("runs"), &fx.repo).await;
let mut browser = cdp::Browser::launch(&chrome)
.await
.unwrap_or_else(|e| panic!("could not start Chrome at {}: {e}", chrome.display()));
let w = Duration::from_secs(30);
for (width, height, mobile) in [(1280u32, 900u32, false), (390, 844, true)] {
for theme in ["light", "dark"] {
let tag = format!("run detail @{width}px {theme}");
let page = browser
.open_page(&format!("{base}#/runs/{id}"), width, height, mobile)
.await
.unwrap_or_else(|e| panic!("{tag}: open: {e}"));
browser
.wait_for(&page, "!!document.documentElement", w)
.await
.unwrap_or_else(|e| panic!("{tag}: document never ready: {e}"));
browser
.eval(
&page,
&format!("document.documentElement.dataset.theme = '{theme}'; true"),
)
.await
.unwrap();
browser
.wait_for(&page, "!!document.querySelector('#run-land .land-top')", w)
.await
.unwrap_or_else(|e| panic!("{tag}: land never rendered: {e}"));
let bare = browser
.eval(
&page,
"(() => { const out = []; const walk = document.createTreeWalker(\
document.getElementById('run-land'), NodeFilter.SHOW_TEXT); \
while (walk.nextNode()) { const t = walk.currentNode.textContent.trim(); \
if (t === 'null' || t === 'undefined') out.push(t); } return out; })()",
)
.await
.unwrap();
assert_eq!(bare, serde_json::json!([]), "{tag}: stringified child");
let strip = browser
.eval(
&page,
"document.getElementById('advise-strip').getBoundingClientRect().height",
)
.await
.unwrap();
let strip = strip.as_f64().unwrap_or(f64::MAX);
assert!(strip > 0.0 && strip < 120.0, "{tag}: strip height {strip}");
assert_eq!(
browser
.eval(
&page,
"document.querySelectorAll('#view-run svg.advise, #advise-converge').length"
)
.await
.unwrap(),
0
);
browser
.eval(
&page,
&format!("location.hash = '#/runs/{id}/report'; true"),
)
.await
.unwrap();
browser
.wait_for(
&page,
"(() => { const r = document.getElementById('run-report'); \
return !!r && r.offsetParent !== null; })()",
w,
)
.await
.unwrap_or_else(|e| panic!("{tag}: report tab: {e}"));
assert_eq!(
browser
.eval(
&page,
"document.getElementById('run-tabpanel-overview').hidden"
)
.await
.unwrap(),
true,
"{tag}"
);
browser
.eval(&page, "fetch('/api/runs').then(() => true)")
.await
.unwrap();
browser.settle(Duration::from_millis(400)).await.unwrap();
let after = browser
.eval(
&page,
"({ hash: location.hash, report: !document.getElementById('run-tabpanel-report').hidden, \
sel: document.getElementById('run-tab-report').getAttribute('aria-selected') })",
)
.await
.unwrap();
assert_eq!(after["hash"], format!("#/runs/{id}/report"), "{tag}");
assert_eq!(after["report"], true, "{tag}");
assert_eq!(after["sel"], "true", "{tag}");
browser
.eval(
&page,
&format!("location.hash = '#/runs/{id}/timeline'; true"),
)
.await
.unwrap();
browser
.wait_for(
&page,
"document.querySelectorAll('#run-events .tl-group').length === 2",
w,
)
.await
.unwrap_or_else(|e| panic!("{tag}: timeline groups: {e}"));
browser.close_page(&page).await;
}
}
let page = browser
.open_page(&format!("{base}#/runs"), 1280, 900, false)
.await
.expect("open");
browser
.wait_for(
&page,
"!!document.querySelector('.state-chip[data-key=all]')",
w,
)
.await
.expect("segmented control");
browser
.eval(
&page,
"document.querySelector('.state-chip[data-key=all]').click(); true",
)
.await
.unwrap();
browser
.wait_for(
&page,
"!!document.querySelector('#view-runs a.card.run-card')",
w,
)
.await
.expect("rows");
let nested = browser
.eval(
&page,
"(() => [...document.querySelectorAll('#view-runs *')].filter((e) => { \
const o = getComputedStyle(e).overflowY; \
return (o === 'auto' || o === 'scroll') && e.scrollHeight > e.clientHeight + 1; \
}).map((e) => e.className || e.tagName))()",
)
.await
.unwrap();
assert_eq!(
nested,
serde_json::json!([]),
"nested scroller in the list pane"
);
browser.close_page(&page).await;
}
#[tokio::test]
async fn queue_row_click_previews_task_in_split_pane() {
let Some(chrome) = cdp::find_chrome() else {
assert!(
std::env::var_os("CI").is_none(),
"CI is set but no Chrome/Chromium was found (set MAGI_CHROME)"
);
eprintln!("SKIP web_render: no Chrome/Chromium found (set MAGI_CHROME to run it)");
return;
};
let guard = common::home_lock().await;
let fx = common::fixture(guard, common::Judges::Unanimous, false);
let home = fx.tmp.path().join("magi-home");
let queue = Queue::at(home.join("queue"));
let talks = Talks::at(home.join("talks"));
let mut run = RunState::new(
fx.repo.clone(),
"main".to_owned(),
"0000000".to_owned(),
"A run".to_owned(),
fx.config.clone(),
);
run.id = "20260901-000001-aa01".to_owned();
run.status = RunStatus::Reviewing;
run.save_under(&home).expect("seed run");
let ids = seed_tasks(&queue, &fx.repo, &run.id);
let base = serve(&home, queue, talks, home.join("runs"), &fx.repo).await;
let mut browser = cdp::Browser::launch(&chrome)
.await
.unwrap_or_else(|e| panic!("could not start Chrome at {}: {e}", chrome.display()));
let w = Duration::from_secs(30);
let mut failures: Vec<String> = Vec::new();
for width in [1280u32, 1950] {
let tag = format!("queue click @{width}px");
let page = browser
.open_page(&format!("{base}#/queue"), width, 900, false)
.await
.expect("open");
let all = ids
.iter()
.map(|id| format!("!!document.querySelector('a[href=\"#/queue/{id}\"]')"))
.collect::<Vec<_>>()
.join(" && ");
browser.wait_for(&page, &all, w).await.expect("rows");
browser
.eval(
&page,
"document.querySelectorAll('details.list-section').forEach((d) => { d.open = true; })",
)
.await
.expect("expand");
browser
.wait_for(&page, "!!document.querySelector('main[data-split]')", w)
.await
.unwrap_or_else(|e| panic!("{tag}: not in the two-pane layout: {e}"));
browser.settle(Duration::from_millis(300)).await.unwrap();
for id in &ids {
let point = browser
.eval(
&page,
&format!(
"(() => {{ const row = document.querySelector('li.card[data-task-id=\"{id}\"]'); \
if (!row) return null; row.scrollIntoView({{ block: 'center' }}); \
const c = row.querySelector('.card-top .chip, .card-top > span'); \
if (!c) return null; const r = c.getBoundingClientRect(); \
const x = r.left + r.width / 2, y = r.top + r.height / 2; \
const t = document.elementFromPoint(x, y); \
return {{ x, y, inRow: !!t && row.contains(t), title: row.querySelector('.card-title').innerText }}; }})()"
),
)
.await
.unwrap_or_else(|e| panic!("{tag}: locate {id}: {e}"));
if point.is_null() || point["inRow"] != true {
failures.push(format!(
"{tag}: {id}: no clickable chip inside the row: {point}"
));
continue;
}
for kind in ["mousePressed", "mouseReleased"] {
browser
.call(
Some(&page.session),
"Input.dispatchMouseEvent",
serde_json::json!({
"type": kind, "x": point["x"], "y": point["y"],
"button": "left", "clickCount": 1,
}),
)
.await
.expect("mouse");
}
let want = format!("#/tasks/{id}");
let title = point["title"].as_str().unwrap_or_default();
let own = if title.is_empty() || title.starts_with("Instruction for") {
"true".to_owned()
} else {
format!(
"document.getElementById('task-h').textContent.includes({})",
Value::from(title)
)
};
let shown = format!(
"location.hash === {want:?} \
&& !document.getElementById('view-task').hidden \
&& document.getElementById('split-empty').hidden \
&& !document.getElementById('task-h').textContent.includes('Loading task') \
&& !document.getElementById('task-h').textContent.includes('could not be loaded') \
&& document.getElementById('task-h').textContent.trim() !== '' \
&& {own}"
);
if browser
.wait_for(&page, &shown, Duration::from_secs(5))
.await
.is_err()
{
let seen = browser
.eval(
&page,
"({ hash: location.hash, task: document.getElementById('view-task').hidden, \
empty: document.getElementById('split-empty').hidden, \
h: document.getElementById('task-h').textContent })",
)
.await
.unwrap_or_default();
failures.push(format!(
"{tag}: {id}: clicking the row did not open it: {seen}"
));
continue;
}
}
browser.close_page(&page).await;
}
assert!(
failures.is_empty(),
"queue row clicks:\n {}",
failures.join("\n ")
);
}