diff --git a/.isu/issues.json b/.isu/issues.json index cd5fd01..4379c8b 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -3085,7 +3085,7 @@ ], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-05-22T02:22:34Z" }, { diff --git a/crates/e2e/src/lib.rs b/crates/e2e/src/lib.rs index 8187db6..92f7162 100644 --- a/crates/e2e/src/lib.rs +++ b/crates/e2e/src/lib.rs @@ -12,6 +12,7 @@ pub mod console; pub mod dom_dump; +pub mod panic; pub mod png_out; pub mod render; pub mod scenario; diff --git a/crates/e2e/src/main.rs b/crates/e2e/src/main.rs index 48d586e..eaaa239 100644 --- a/crates/e2e/src/main.rs +++ b/crates/e2e/src/main.rs @@ -121,7 +121,8 @@ fn print_usage() { } fn single_shot(url: &str, width: u32, height: u32, out: &Path) -> ExitCode { - match std::panic::catch_unwind(|| we_e2e::render::render(url, width, height)) { + let result = we_e2e::panic::catch(|| we_e2e::render::render(url, width, height)); + match result { Ok(Ok(o)) => { let png = we_e2e::png_out::bgra_to_png(&o.bgra, o.width, o.height); if let Some(parent) = out.parent() { @@ -141,13 +142,34 @@ fn single_shot(url: &str, width: u32, height: u32, out: &Path) -> ExitCode { eprintln!("render error: {e}"); ExitCode::from(1) } - Err(_) => { - eprintln!("PANIC during render of {url}"); + Err(report) => { + let panic_path = panic_artifact_path(out); + if let Some(parent) = panic_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&panic_path, report.render()); + eprintln!( + "PANIC during render of {url}: {} (see {})", + report.summary(), + panic_path.display() + ); ExitCode::from(101) } } } +/// Compute the `panic.txt` artifact path that sits alongside `out`. +/// For `dir/foo.png` we produce `dir/foo.png.panic.txt`. +fn panic_artifact_path(out: &Path) -> PathBuf { + let parent = out.parent().filter(|p| !p.as_os_str().is_empty()); + let stem = out.file_name().and_then(|n| n.to_str()).unwrap_or("out"); + let panic_name = format!("{stem}.panic.txt"); + match parent { + Some(p) => p.join(panic_name), + None => PathBuf::from(panic_name), + } +} + fn run_scenario(path: &Path, out_dir: &Path) -> ExitCode { let scenario = match we_e2e::scenario::parse(path) { Ok(s) => s, @@ -158,10 +180,24 @@ fn run_scenario(path: &Path, out_dir: &Path) -> ExitCode { }; let _ = std::fs::create_dir_all(out_dir); - let report = match std::panic::catch_unwind(|| we_e2e::scenario::run(&scenario, out_dir)) { + // `we_e2e::scenario::run` already catches panics per-command and writes + // `/panics/L.txt` artifacts. We still wrap the whole + // call as a safety net for panics outside the per-command body (e.g. + // during initial setup). + let report = match we_e2e::panic::catch(|| we_e2e::scenario::run(&scenario, out_dir)) { Ok(r) => r, - Err(_) => { - eprintln!("PANIC during scenario {}", path.display()); + Err(report) => { + let panic_path = out_dir.join("panic.txt"); + if let Some(parent) = panic_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&panic_path, report.render()); + eprintln!( + "PANIC during scenario {}: {} (see {})", + path.display(), + report.summary(), + panic_path.display() + ); return ExitCode::from(101); } }; diff --git a/crates/e2e/src/panic.rs b/crates/e2e/src/panic.rs new file mode 100644 index 0000000..e656c43 --- /dev/null +++ b/crates/e2e/src/panic.rs @@ -0,0 +1,182 @@ +//! Panic capture helper for the e2e harness. +//! +//! Runs a closure under [`std::panic::catch_unwind`] with a custom panic hook +//! that records the panic message, source location, and backtrace into a +//! [`PanicReport`]. Lets per-scenario (or per-command) execution survive a +//! single crash so a multi-scenario `--real-web` batch is not aborted by one +//! buggy site. +//! +//! Implementation notes: +//! - The panic hook is process-global. We install it once lazily on the first +//! call to [`catch`] and never remove it; the hook records into a +//! thread-local slot. This lets multiple threads (e.g. concurrent `cargo +//! test` workers) each run their own [`catch`] safely. +//! - The capture is best-effort: if the panic originates on a thread spawned +//! inside the guarded closure, the parent's thread-local slot will not be +//! populated; we still fall back to the raw payload from `catch_unwind`. + +use std::any::Any; +use std::backtrace::Backtrace; +use std::cell::RefCell; +use std::panic::{self, AssertUnwindSafe}; +use std::sync::Once; + +/// Captured panic info from a [`catch`] invocation. +#[derive(Debug, Clone)] +pub struct PanicReport { + pub message: String, + pub location: Option, + pub backtrace: String, +} + +impl PanicReport { + /// Format as plain text suitable for writing to `panic.txt`. + pub fn render(&self) -> String { + let mut s = String::new(); + s.push_str("PANIC\n"); + s.push_str(&format!("message: {}\n", self.message)); + if let Some(loc) = &self.location { + s.push_str(&format!("location: {}\n", loc)); + } + s.push_str("\nbacktrace:\n"); + s.push_str(&self.backtrace); + if !self.backtrace.ends_with('\n') { + s.push('\n'); + } + s + } + + /// One-line summary, useful for the scenario step log. + pub fn summary(&self) -> String { + match &self.location { + Some(loc) => format!("panic at {loc}: {}", self.message), + None => format!("panic: {}", self.message), + } + } +} + +thread_local! { + static CAPTURED: RefCell> = const { RefCell::new(None) }; + /// `true` while this thread is inside a [`catch`] call. Used to decide + /// whether our installed hook should swallow the default panic message + /// (yes, for explicitly caught panics) or chain to the prior hook + /// (no, for stray panics outside the harness). + static GUARDED: RefCell = const { RefCell::new(false) }; +} + +static INSTALL_HOOK: Once = Once::new(); + +fn install_hook_once() { + INSTALL_HOOK.call_once(|| { + let prior_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + let guarded = GUARDED.with(|g| *g.borrow()); + if !guarded { + prior_hook(info); + return; + } + let message = payload_message(info.payload()); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())); + let backtrace = Backtrace::force_capture().to_string(); + CAPTURED.with(|c| { + *c.borrow_mut() = Some(PanicReport { + message, + location, + backtrace, + }); + }); + })); + }); +} + +/// Run `f` under `catch_unwind`, returning the captured panic info if it +/// unwound. Installs (once per process) a panic hook that records into a +/// thread-local slot, so concurrent threads can each call `catch` +/// independently without racing. +pub fn catch R>(f: F) -> Result { + install_hook_once(); + CAPTURED.with(|c| *c.borrow_mut() = None); + GUARDED.with(|g| *g.borrow_mut() = true); + + let result = panic::catch_unwind(AssertUnwindSafe(f)); + + GUARDED.with(|g| *g.borrow_mut() = false); + + match result { + Ok(r) => Ok(r), + Err(payload) => { + let captured = CAPTURED.with(|c| c.borrow_mut().take()); + Err(captured.unwrap_or_else(|| PanicReport { + message: payload_message(&*payload), + location: None, + backtrace: String::new(), + })) + } + } +} + +fn payload_message(payload: &dyn Any) -> String { + if let Some(s) = payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catch_returns_ok_for_clean_run() { + let result = catch(|| 42); + assert_eq!(result.ok(), Some(42)); + } + + #[test] + fn catch_captures_string_panic_message() { + let result = catch(|| { + panic!("explicit panic in test"); + }); + let report = result.expect_err("expected panic report"); + assert!( + report.message.contains("explicit panic in test"), + "message was: {:?}", + report.message + ); + assert!( + report + .location + .as_ref() + .map(|loc| loc.contains("panic.rs")) + .unwrap_or(false), + "location was: {:?}", + report.location + ); + let rendered = report.render(); + assert!(rendered.starts_with("PANIC\n")); + assert!(rendered.contains("explicit panic in test")); + } + + #[test] + fn catch_captures_formatted_panic() { + let result = catch(|| { + let x = 1; + panic!("value was {x}"); + }); + let report = result.expect_err("expected panic report"); + assert!(report.message.contains("value was 1")); + } + + #[test] + fn catch_is_repeatable_on_the_same_thread() { + let _ = catch(|| {}); + let result = catch(|| panic!("second run")); + let report = result.expect_err("expected panic report"); + assert!(report.message.contains("second run")); + } +} diff --git a/crates/e2e/src/scenario.rs b/crates/e2e/src/scenario.rs index 8c54b14..f0f5a08 100644 --- a/crates/e2e/src/scenario.rs +++ b/crates/e2e/src/scenario.rs @@ -18,7 +18,8 @@ //! dump_console # write captured console //! assert_dom_contains # fail scenario if DOM dump does not contain //! assert_console_contains # fail scenario if console does not contain -//! expect_no_panics # marker (we always abort on panics; here for documentation) +//! expect_no_panics # marker; the harness already catches every panic. +//! panic_now [msg] # debug-only: deliberately panic to exercise the catcher. //! ``` //! //! Interaction commands (click, type, etc.) will be added in a follow-up. @@ -58,6 +59,7 @@ pub enum Cmd { AssertDomContains(String), AssertConsoleContains(String), ExpectNoPanics, + PanicNow(String), } pub struct Scenario { @@ -201,6 +203,15 @@ fn parse_line(line: &str) -> Result { "assert_dom_contains" => Ok(Cmd::AssertDomContains(unquote(rest))), "assert_console_contains" => Ok(Cmd::AssertConsoleContains(unquote(rest))), "expect_no_panics" => Ok(Cmd::ExpectNoPanics), + "panic_now" => { + let msg = unquote(rest); + let msg = if msg.is_empty() { + "panic_now (scenario debug command)".to_string() + } else { + msg + }; + Ok(Cmd::PanicNow(msg)) + } other => Err(format!("unknown command: {other:?}")), } } @@ -225,6 +236,11 @@ pub struct RunReport { /// Execute a scenario. Paths in `dump_*`/`screenshot` are resolved relative /// to `out_dir`. Returns a `RunReport` describing the run. +/// +/// Each command is executed under [`crate::panic::catch`]: a panic in one +/// command writes `/panics/L.txt` and is recorded as a +/// failing step, but the runner continues with subsequent commands so the +/// cascade is visible in the report. pub fn run(scenario: &Scenario, out_dir: &Path) -> RunReport { let mut viewport = (800u32, 600u32); let mut current: Option = None; @@ -236,260 +252,295 @@ pub fn run(scenario: &Scenario, out_dir: &Path) -> RunReport { we_browser::loader::clear_service_worker_caches(); for (lineno, cmd) in &scenario.commands { - match cmd { - Cmd::Goto(url) => match render(url, viewport.0, viewport.1) { + let lineno_v = *lineno; + let mut step_log: Vec<(usize, bool, String)> = Vec::new(); + let mut new_warnings: Vec = Vec::new(); + + let outcome = crate::panic::catch(|| { + execute_cmd( + lineno_v, + cmd, + &mut viewport, + &mut current, + &mut step_log, + &mut new_warnings, + scenario_dir, + out_dir, + ); + }); + + for entry in &step_log { + if !entry.1 { + all_ok = false; + } + } + steps.extend(step_log); + warnings.extend(new_warnings); + + if let Err(report) = outcome { + let panic_path = out_dir.join("panics").join(format!("L{lineno_v}.txt")); + if let Some(parent) = panic_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&panic_path, report.render()); + steps.push(( + lineno_v, + false, + format!( + "sev-crash {} (see {})", + report.summary(), + panic_path.display() + ), + )); + all_ok = false; + current = None; + } + } + + we_browser::loader::set_network_offline(false); + + RunReport { + scenario: scenario.source.clone(), + passed: all_ok, + steps, + warnings, + } +} + +#[allow(clippy::too_many_arguments)] +fn execute_cmd( + lineno: usize, + cmd: &Cmd, + viewport: &mut (u32, u32), + current: &mut Option, + steps: &mut Vec<(usize, bool, String)>, + warnings: &mut Vec, + scenario_dir: &Path, + out_dir: &Path, +) { + match cmd { + Cmd::PanicNow(msg) => { + panic!("{msg}"); + } + Cmd::Goto(url) => match render(url, viewport.0, viewport.1) { + Ok(out) => { + warnings.extend(out.warnings.iter().cloned()); + steps.push((lineno, true, format!("goto {url} ok"))); + *current = Some(out); + } + Err(e) => { + steps.push((lineno, false, format!("goto {url}: {e}"))); + *current = None; + } + }, + Cmd::GotoAs { url, path } => { + match crate::render::render_file_as_url( + &path.to_string_lossy(), + url, + viewport.0, + viewport.1, + ) { Ok(out) => { warnings.extend(out.warnings.iter().cloned()); - steps.push((*lineno, true, format!("goto {url} ok"))); - current = Some(out); + steps.push((lineno, true, format!("goto_as {url} {} ok", path.display()))); + *current = Some(out); } Err(e) => { - steps.push((*lineno, false, format!("goto {url}: {e}"))); - all_ok = false; - current = None; - } - }, - Cmd::GotoAs { url, path } => { - match crate::render::render_file_as_url( - &path.to_string_lossy(), - url, - viewport.0, - viewport.1, - ) { - Ok(out) => { - warnings.extend(out.warnings.iter().cloned()); - steps.push(( - *lineno, - true, - format!("goto_as {url} {} ok", path.display()), - )); - current = Some(out); - } - Err(e) => { - steps.push(( - *lineno, - false, - format!("goto_as {url} {}: {e}", path.display()), - )); - all_ok = false; - current = None; - } + steps.push(( + lineno, + false, + format!("goto_as {url} {}: {e}", path.display()), + )); + *current = None; } } - Cmd::Viewport(w, h) => { - viewport = (*w, *h); - steps.push((*lineno, true, format!("viewport {w}x{h}"))); - } - Cmd::Network(offline) => { - we_browser::loader::set_network_offline(*offline); - let state = if *offline { "offline" } else { "online" }; - steps.push((*lineno, true, format!("network {state}"))); - } - Cmd::CachePut { - cache_name, - url, - path, - mime_type, - } => { - let full_path = if path.is_absolute() { - path.clone() - } else { - scenario_dir.join(path) - }; - match ( - we_url::Url::parse(url), - std::fs::read(&full_path).map_err(|e| e.to_string()), - ) { - (Ok(parsed), Ok(body)) => { - let response = we_browser::loader::CachedResponse::new( - cache_name, &parsed, mime_type, body, - ); - we_browser::loader::put_service_worker_cache_response(response); - steps.push(( - *lineno, - true, - format!( - "cache_put {cache_name} {url} {} {mime_type}", - full_path.display() - ), - )); - } - (Err(e), _) => { - steps.push((*lineno, false, format!("cache_put: invalid URL {url}: {e}"))); - all_ok = false; - } - (_, Err(e)) => { - steps.push(( - *lineno, - false, - format!("cache_put: read {}: {e}", full_path.display()), - )); - all_ok = false; - } + } + Cmd::Viewport(w, h) => { + *viewport = (*w, *h); + steps.push((lineno, true, format!("viewport {w}x{h}"))); + } + Cmd::Network(offline) => { + we_browser::loader::set_network_offline(*offline); + let state = if *offline { "offline" } else { "online" }; + steps.push((lineno, true, format!("network {state}"))); + } + Cmd::CachePut { + cache_name, + url, + path, + mime_type, + } => { + let full_path = if path.is_absolute() { + path.clone() + } else { + scenario_dir.join(path) + }; + match ( + we_url::Url::parse(url), + std::fs::read(&full_path).map_err(|e| e.to_string()), + ) { + (Ok(parsed), Ok(body)) => { + let response = we_browser::loader::CachedResponse::new( + cache_name, &parsed, mime_type, body, + ); + we_browser::loader::put_service_worker_cache_response(response); + steps.push(( + lineno, + true, + format!( + "cache_put {cache_name} {url} {} {mime_type}", + full_path.display() + ), + )); } - } - Cmd::CachePutStatus { - cache_name, - url, - status, - reason, - path, - mime_type, - } => { - let full_path = if path.is_absolute() { - path.clone() - } else { - scenario_dir.join(path) - }; - match ( - we_url::Url::parse(url), - std::fs::read(&full_path).map_err(|e| e.to_string()), - ) { - (Ok(parsed), Ok(body)) => { - let mut response = we_browser::loader::CachedResponse::new( - cache_name, &parsed, mime_type, body, - ); - response.status = *status; - response.reason = reason.clone(); - we_browser::loader::put_service_worker_cache_response(response); - steps.push(( - *lineno, - true, - format!( - "cache_put_status {cache_name} {url} {status} {} {mime_type}", - full_path.display() - ), - )); - } - (Err(e), _) => { - steps.push(( - *lineno, - false, - format!("cache_put_status: invalid URL {url}: {e}"), - )); - all_ok = false; - } - (_, Err(e)) => { - steps.push(( - *lineno, - false, - format!("cache_put_status: read {}: {e}", full_path.display()), - )); - all_ok = false; - } + (Err(e), _) => { + steps.push((lineno, false, format!("cache_put: invalid URL {url}: {e}"))); + } + (_, Err(e)) => { + steps.push(( + lineno, + false, + format!("cache_put: read {}: {e}", full_path.display()), + )); } } - Cmd::Screenshot(path) => match ¤t { - Some(out) => { - let png = crate::png_out::bgra_to_png(&out.bgra, out.width, out.height); - let full = out_dir.join(path); - if let Some(parent) = full.parent() { - let _ = std::fs::create_dir_all(parent); - } - match std::fs::write(&full, &png) { - Ok(()) => { - steps.push((*lineno, true, format!("screenshot {}", full.display()))) - } - Err(e) => { - steps.push((*lineno, false, format!("screenshot: write {e}"))); - all_ok = false; - } - } + } + Cmd::CachePutStatus { + cache_name, + url, + status, + reason, + path, + mime_type, + } => { + let full_path = if path.is_absolute() { + path.clone() + } else { + scenario_dir.join(path) + }; + match ( + we_url::Url::parse(url), + std::fs::read(&full_path).map_err(|e| e.to_string()), + ) { + (Ok(parsed), Ok(body)) => { + let mut response = we_browser::loader::CachedResponse::new( + cache_name, &parsed, mime_type, body, + ); + response.status = *status; + response.reason = reason.clone(); + we_browser::loader::put_service_worker_cache_response(response); + steps.push(( + lineno, + true, + format!( + "cache_put_status {cache_name} {url} {status} {} {mime_type}", + full_path.display() + ), + )); } - None => { - steps.push((*lineno, false, "screenshot: no page loaded".into())); - all_ok = false; + (Err(e), _) => { + steps.push(( + lineno, + false, + format!("cache_put_status: invalid URL {url}: {e}"), + )); } - }, - Cmd::DumpDom(path) => match ¤t { - Some(out) => { - let txt = crate::dom_dump::dump(&out.doc); - let full = out_dir.join(path); - if let Some(parent) = full.parent() { - let _ = std::fs::create_dir_all(parent); - } - match std::fs::write(&full, txt) { - Ok(()) => { - steps.push((*lineno, true, format!("dump_dom {}", full.display()))) - } - Err(e) => { - steps.push((*lineno, false, format!("dump_dom: {e}"))); - all_ok = false; - } - } + (_, Err(e)) => { + steps.push(( + lineno, + false, + format!("cache_put_status: read {}: {e}", full_path.display()), + )); } - None => { - steps.push((*lineno, false, "dump_dom: no page loaded".into())); - all_ok = false; + } + } + Cmd::Screenshot(path) => match current { + Some(out) => { + let png = crate::png_out::bgra_to_png(&out.bgra, out.width, out.height); + let full = out_dir.join(path); + if let Some(parent) = full.parent() { + let _ = std::fs::create_dir_all(parent); } - }, - Cmd::DumpConsole(path) => match ¤t { - Some(out) => { - let txt = out.console.joined(); - let full = out_dir.join(path); - if let Some(parent) = full.parent() { - let _ = std::fs::create_dir_all(parent); - } - match std::fs::write(&full, txt) { - Ok(()) => { - steps.push((*lineno, true, format!("dump_console {}", full.display()))) - } - Err(e) => { - steps.push((*lineno, false, format!("dump_console: {e}"))); - all_ok = false; - } + match std::fs::write(&full, &png) { + Ok(()) => steps.push((lineno, true, format!("screenshot {}", full.display()))), + Err(e) => { + steps.push((lineno, false, format!("screenshot: write {e}"))); } } - None => { - steps.push((*lineno, false, "dump_console: no page loaded".into())); - all_ok = false; + } + None => { + steps.push((lineno, false, "screenshot: no page loaded".into())); + } + }, + Cmd::DumpDom(path) => match current { + Some(out) => { + let txt = crate::dom_dump::dump(&out.doc); + let full = out_dir.join(path); + if let Some(parent) = full.parent() { + let _ = std::fs::create_dir_all(parent); } - }, - Cmd::AssertDomContains(needle) => match ¤t { - Some(out) => { - let dump = crate::dom_dump::dump(&out.doc); - if dump.contains(needle.as_str()) { - steps.push((*lineno, true, format!("dom contains {needle:?}"))); - } else { - steps.push((*lineno, false, format!("dom does NOT contain {needle:?}"))); - all_ok = false; + match std::fs::write(&full, txt) { + Ok(()) => steps.push((lineno, true, format!("dump_dom {}", full.display()))), + Err(e) => { + steps.push((lineno, false, format!("dump_dom: {e}"))); } } - None => { - steps.push((*lineno, false, "assert_dom_contains: no page".into())); - all_ok = false; + } + None => { + steps.push((lineno, false, "dump_dom: no page loaded".into())); + } + }, + Cmd::DumpConsole(path) => match current { + Some(out) => { + let txt = out.console.joined(); + let full = out_dir.join(path); + if let Some(parent) = full.parent() { + let _ = std::fs::create_dir_all(parent); } - }, - Cmd::AssertConsoleContains(needle) => match ¤t { - Some(out) => { - if out.console.joined().contains(needle.as_str()) { - steps.push((*lineno, true, format!("console contains {needle:?}"))); - } else { - steps.push(( - *lineno, - false, - format!("console does NOT contain {needle:?}"), - )); - all_ok = false; + match std::fs::write(&full, txt) { + Ok(()) => { + steps.push((lineno, true, format!("dump_console {}", full.display()))) + } + Err(e) => { + steps.push((lineno, false, format!("dump_console: {e}"))); } } - None => { - steps.push((*lineno, false, "assert_console_contains: no page".into())); - all_ok = false; + } + None => { + steps.push((lineno, false, "dump_console: no page loaded".into())); + } + }, + Cmd::AssertDomContains(needle) => match current { + Some(out) => { + let dump = crate::dom_dump::dump(&out.doc); + if dump.contains(needle.as_str()) { + steps.push((lineno, true, format!("dom contains {needle:?}"))); + } else { + steps.push((lineno, false, format!("dom does NOT contain {needle:?}"))); + } + } + None => { + steps.push((lineno, false, "assert_dom_contains: no page".into())); + } + }, + Cmd::AssertConsoleContains(needle) => match current { + Some(out) => { + if out.console.joined().contains(needle.as_str()) { + steps.push((lineno, true, format!("console contains {needle:?}"))); + } else { + steps.push(( + lineno, + false, + format!("console does NOT contain {needle:?}"), + )); } - }, - Cmd::ExpectNoPanics => { - steps.push((*lineno, true, "expect_no_panics".into())); } + None => { + steps.push((lineno, false, "assert_console_contains: no page".into())); + } + }, + Cmd::ExpectNoPanics => { + steps.push((lineno, true, "expect_no_panics".into())); } } - - we_browser::loader::set_network_offline(false); - - RunReport { - scenario: scenario.source.clone(), - passed: all_ok, - steps, - warnings, - } } diff --git a/crates/e2e/tests/panic_catcher.rs b/crates/e2e/tests/panic_catcher.rs new file mode 100644 index 0000000..2aacf6d --- /dev/null +++ b/crates/e2e/tests/panic_catcher.rs @@ -0,0 +1,129 @@ +//! Integration tests for the e2e panic catcher. +//! +//! These exercise the catcher both in isolation and via the scenario runner +//! to confirm that: +//! - A panic inside a command is captured rather than aborting the process. +//! - A `panic.txt` artifact is written next to the scenario output. +//! - Subsequent commands in the same scenario still run after the panic. + +use std::path::PathBuf; + +use we_e2e::panic; +use we_e2e::scenario::{self, Cmd}; + +fn tmp_dir(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!("we-e2e-panic-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).expect("create tmp dir"); + p +} + +#[test] +fn catch_captures_explicit_panic_and_returns_message() { + let report = panic::catch(|| panic!("intentional in unit test")).expect_err("expected panic"); + assert!( + report.message.contains("intentional in unit test"), + "message was: {:?}", + report.message + ); + let rendered = report.render(); + assert!(rendered.contains("intentional in unit test")); + assert!(rendered.starts_with("PANIC\n")); +} + +#[test] +fn scenario_runner_survives_panic_now_and_writes_artifact() { + let dir = tmp_dir("scenario-runner-survives"); + let scenario_path = dir.join("panicky.we"); + + // Use `goto about:blank` first so subsequent commands have page state to + // chew on; then panic; then a follow-up assertion that should still run + // (it will fail because we cleared `current` on panic, which is exactly + // the cascade we want surfaced). + std::fs::write( + &scenario_path, + "goto about:blank\n\ + panic_now \"boom from scenario\"\n\ + assert_dom_contains \"about\"\n", + ) + .expect("write scenario"); + + let parsed = scenario::parse(&scenario_path).expect("parse scenario"); + assert!( + parsed + .commands + .iter() + .any(|(_, c)| matches!(c, Cmd::PanicNow(_))), + "scenario should contain a panic_now command" + ); + + let report = scenario::run(&parsed, &dir); + + assert!(!report.passed, "scenario with panic_now should not pass"); + + let panic_step = report + .steps + .iter() + .find(|(_, ok, msg)| !*ok && msg.contains("sev-crash")) + .expect("expected a sev-crash step entry"); + assert!( + panic_step.2.contains("boom from scenario"), + "panic step message should include the panic text: {:?}", + panic_step.2 + ); + + // Subsequent step must have executed (post-panic cascade visible). + let post_panic = report + .steps + .iter() + .any(|(_, _, msg)| msg.contains("assert_dom_contains: no page")); + assert!( + post_panic, + "expected the post-panic assert_dom_contains to run and fail with no-page; steps were: {:?}", + report.steps + ); + + // panic.txt artifact must exist for the panicking line. + let panics_dir = dir.join("panics"); + let entries: Vec<_> = std::fs::read_dir(&panics_dir) + .expect("read panics dir") + .filter_map(|e| e.ok()) + .collect(); + assert!( + !entries.is_empty(), + "expected at least one panic.txt artifact in {}", + panics_dir.display() + ); + let panic_file = &entries[0].path(); + let panic_contents = std::fs::read_to_string(panic_file).expect("read panic.txt"); + assert!(panic_contents.contains("boom from scenario")); + assert!(panic_contents.starts_with("PANIC\n")); +} + +#[test] +fn scenario_runner_panic_does_not_block_later_viewport_command() { + let dir = tmp_dir("scenario-later-viewport"); + let scenario_path = dir.join("panic_then_viewport.we"); + + std::fs::write( + &scenario_path, + "panic_now \"early crash\"\n\ + viewport 320 240\n", + ) + .expect("write scenario"); + + let parsed = scenario::parse(&scenario_path).expect("parse scenario"); + let report = scenario::run(&parsed, &dir); + + assert!(!report.passed); + let viewport_ran = report + .steps + .iter() + .any(|(_, ok, msg)| *ok && msg.contains("viewport 320x240")); + assert!( + viewport_ran, + "viewport command must still run after an earlier panic; steps: {:?}", + report.steps + ); +}