diff --git a/.gitignore b/.gitignore index ea8c4bf..169c32d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/crates/e2e/artifacts/ diff --git a/CLAUDE.md b/CLAUDE.md index 3658a73..88f8057 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,8 @@ browser ─┬─ platform (macOS FFI: libobjc, AppKit, CoreGraphics, Metal) ├─ url ├─ encoding └─ image (standalone: PNG, JPEG, GIF, WebP) + +e2e ─── browser, render, layout, ... (headless harness; no AppKit) ``` Standalone crates (no workspace deps): `platform`, `encoding`, `css`, `dom`, `crypto`, `text`, `image`. @@ -100,9 +102,18 @@ The ONLY allowed system APIs are: 4. `cargo clippy --workspace -- -D warnings` 5. `cargo fmt --all --check` 6. `cargo test --workspace` -7. Commit and push -8. Create PR via tangled: `tangled pr create --title "..." --body "..."` -9. After merge, clean up: `git worktree remove ../we-` +7. If the change touches parsing, styling, layout, rendering, the UA stylesheet, + forms, scripting, or anything user-visible: run the e2e smoke suite and view + the screenshots: + ```sh + cargo run -p we-e2e -- --scenario crates/e2e/scenarios/smoke.we --out-dir crates/e2e/artifacts + ``` + If you fix a bug the harness exposed, add a scenario asserting the new + behaviour. If you spot a new bug, file a tangled issue with the + `e2e-smoke` label (see "E2E Harness" section). +8. Commit and push +9. Create PR via tangled: `tangled pr create --title "..." --body "..."` +10. After merge, clean up: `git worktree remove ../we-` ## Tangled CLI Cheat Sheet @@ -130,5 +141,75 @@ tangled repo show | Acid1/Acid2 | loaded via URL | manual: `cargo run -p we-browser -- ` | | WPT | `tests/wpt/` | `cargo test -p we-browser --test wpt` | | Unit tests | each crate | `cargo test --workspace` | +| E2E smoke | `crates/e2e/scenarios/` | `cargo run -p we-e2e -- --scenario crates/e2e/scenarios/smoke.we --out-dir crates/e2e/artifacts` | Test data repos are git submodules under `tests/`. + +## E2E Harness (`we-e2e`) + +Headless visual-and-behaviour test driver. Loads a URL or file through the same +library pipeline the production browser uses (parse → script → style → layout → +software render), then writes a PNG screenshot, a DOM dump, and captured +`console.*` output. No AppKit, no window. Always uses the software renderer. + +Use it after any change that could affect rendering, layout, parsing, scripting, +or the UA stylesheet. The screenshot diff is the cheapest way to catch +regressions you would otherwise miss until a user reports them. + +### Two modes + +```sh +# Single-shot screenshot. +cargo run -p we-e2e -- --url --out \ + [--width 800 --height 600] + +# Run a scenario (recommended). One scenario per logical test. +cargo run -p we-e2e -- --scenario [--out-dir DIR] +``` + +### Scenario DSL + +Line-based, one command per line. `#` starts a comment. + +| Command | Purpose | +|---|---| +| `viewport ` | Set viewport for subsequent `goto`s (default 800x600). | +| `goto ` | Load a page. Replaces prior state. | +| `screenshot ` | Write PNG of current viewport. | +| `dump_dom ` | Write indented text dump of the DOM. | +| `dump_console ` | Write captured console.log/warn/error output. | +| `assert_dom_contains ""` | Fail if the DOM dump lacks ``. | +| `assert_console_contains ""` | Fail if the console capture lacks ``. | + +Output paths are resolved relative to `--out-dir` (default `.`). + +### Smoke suite + +`crates/e2e/scenarios/smoke.we` covers basic HTML, CSS box model, typography, +forms, tables, flex, JS DOM manipulation, positioning, overflow, links, canvas, +and SVG. Run it after non-trivial changes: + +```sh +cargo run -p we-e2e -- --scenario crates/e2e/scenarios/smoke.we --out-dir crates/e2e/artifacts +``` + +Outputs land in `crates/e2e/artifacts/` (gitignored). View the PNGs to spot +regressions; the harness only catches *crashes* and assertion failures +automatically — visual diffs still need your eyes. + +### Adding a new scenario + +1. Drop a test page in `crates/e2e/pages/_.html`. Make it + self-contained (no network dependencies) so the harness stays offline-safe. +2. Add a scenario in `crates/e2e/scenarios/.we` referencing the page. +3. Run it locally, inspect the screenshot, file a tangled issue for any + rendering bug, then commit page + scenario. + +### When you find a bug + +File a tangled issue with `--label e2e-smoke` and include: +- the exact `cargo run -p we-e2e -- ...` repro command, +- the path of the screenshot in `crates/e2e/artifacts/`, +- expected vs actual behaviour. + +The bugs found by the first smoke pass are labelled `e2e-smoke` in tangled. diff --git a/Cargo.lock b/Cargo.lock index 1446598..339c176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,26 @@ dependencies = [ "we-memory", ] +[[package]] +name = "we-e2e" +version = "0.1.0" +dependencies = [ + "we-browser", + "we-css", + "we-dom", + "we-encoding", + "we-html", + "we-image", + "we-js", + "we-layout", + "we-net", + "we-render", + "we-style", + "we-svg", + "we-text", + "we-url", +] + [[package]] name = "we-encoding" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b2ddead..3433c15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/svg", "crates/memory", "crates/browser", + "crates/e2e", ] [workspace.package] diff --git a/PLAN.md b/PLAN.md index c6efd9b..e7da397 100644 --- a/PLAN.md +++ b/PLAN.md @@ -289,3 +289,20 @@ A from-scratch web browser engine in pure Rust. macOS ARM only. Zero external cr | Acid2 | `layout`, `render` | 9 | | Test262 (core language) | `js` | 10 | | WPT (progressive) | all | 11+ | +| E2E smoke (headless screenshots + scripted scenarios) | `e2e` | continuous | + +### E2E Harness + +`crates/e2e` is a continuous-running test layer, not a phase. It drives the +full library pipeline (parse → script → style → layout → software render) with +no AppKit/window, and writes PNG screenshots, DOM dumps, and console captures +for assertion or visual review. + +Usage and scenario DSL are documented in `CLAUDE.md` ("E2E Harness"). Run it +after any change that could affect user-visible output: + +```sh +cargo run -p we-e2e -- --scenario crates/e2e/scenarios/smoke.we --out-dir crates/e2e/artifacts +``` + +Bugs surfaced by this harness should be filed with the `e2e-smoke` label. diff --git a/crates/browser/src/script_loader.rs b/crates/browser/src/script_loader.rs index 32f6cd9..953d933 100644 --- a/crates/browser/src/script_loader.rs +++ b/crates/browser/src/script_loader.rs @@ -8,7 +8,7 @@ use crate::loader::{Resource, ResourceLoader, ResourceRequestType}; use we_dom::{Document, NodeId}; use we_js::compiler; use we_js::parser::Parser; -use we_js::vm::Vm; +use we_js::vm::{ConsoleOutput, Vm}; use we_net::referrer::ReferrerPolicy; use we_url::{Origin, Url}; @@ -212,6 +212,21 @@ pub fn execute_scripts_into_vm( loader: &mut ResourceLoader, base_url: &Url, document_origin: &Origin, +) -> Vm { + execute_scripts_into_vm_with_console(doc, loader, base_url, document_origin, None) +} + +/// Same as [`execute_scripts_into_vm`] but installs `console` on the VM +/// before any scripts run, so callers (e.g. the headless test harness) can +/// capture `console.log` output emitted by the initial script pass. +/// +/// Pass `None` to use the default stdout/stderr console. +pub fn execute_scripts_into_vm_with_console( + doc: Document, + loader: &mut ResourceLoader, + base_url: &Url, + document_origin: &Origin, + console: Option>, ) -> Vm { // Find all + + diff --git a/crates/e2e/pages/08_positioning.html b/crates/e2e/pages/08_positioning.html new file mode 100644 index 0000000..5c9b381 --- /dev/null +++ b/crates/e2e/pages/08_positioning.html @@ -0,0 +1,46 @@ + + + + + + +
Fixed badge
+
+ TL + BR +
Center
+
+ + diff --git a/crates/e2e/pages/09_overflow.html b/crates/e2e/pages/09_overflow.html new file mode 100644 index 0000000..90b829e --- /dev/null +++ b/crates/e2e/pages/09_overflow.html @@ -0,0 +1,31 @@ + + + + + + + +
+

Same long paragraph but in an auto-overflow container — should clip too but ideally show a scrollbar hint.

+

More content.

+

And more.

+

Even more.

+
+ + diff --git a/crates/e2e/pages/10_links.html b/crates/e2e/pages/10_links.html new file mode 100644 index 0000000..7d80bc6 --- /dev/null +++ b/crates/e2e/pages/10_links.html @@ -0,0 +1,23 @@ + + + + + + +

Links

+ +

Section

+

Target of the fragment link.

+ + diff --git a/crates/e2e/pages/11_canvas.html b/crates/e2e/pages/11_canvas.html new file mode 100644 index 0000000..7b225f1 --- /dev/null +++ b/crates/e2e/pages/11_canvas.html @@ -0,0 +1,26 @@ + + + + + + +

Canvas

+ + + + diff --git a/crates/e2e/pages/12_svg.html b/crates/e2e/pages/12_svg.html new file mode 100644 index 0000000..9cfcb66 --- /dev/null +++ b/crates/e2e/pages/12_svg.html @@ -0,0 +1,13 @@ + + + + +

SVG

+ + + + + SVG text + + + diff --git a/crates/e2e/scenarios/smoke.we b/crates/e2e/scenarios/smoke.we new file mode 100644 index 0000000..aebe865 --- /dev/null +++ b/crates/e2e/scenarios/smoke.we @@ -0,0 +1,48 @@ +# Smoke scenario: render every page under pages/ and snapshot. +# Paths are relative to the harness invocation directory. + +viewport 800 600 + +goto crates/e2e/pages/01_basic.html +screenshot 01_basic.png +dump_dom 01_basic.dom.txt +assert_dom_contains "Hello, world!" + +goto crates/e2e/pages/02_box_model.html +screenshot 02_box_model.png + +goto crates/e2e/pages/03_typography.html +screenshot 03_typography.png + +goto crates/e2e/pages/04_forms.html +screenshot 04_forms.png +dump_dom 04_forms.dom.txt + +goto crates/e2e/pages/05_table.html +screenshot 05_table.png + +goto crates/e2e/pages/06_flex.html +screenshot 06_flex.png + +goto crates/e2e/pages/07_js_basic.html +screenshot 07_js_basic.png +dump_console 07_js_basic.console.txt +assert_console_contains "script end" +dump_dom 07_js_basic.dom.txt +assert_dom_contains "Updated by JS" + +goto crates/e2e/pages/08_positioning.html +screenshot 08_positioning.png + +goto crates/e2e/pages/09_overflow.html +screenshot 09_overflow.png + +goto crates/e2e/pages/10_links.html +screenshot 10_links.png + +goto crates/e2e/pages/11_canvas.html +screenshot 11_canvas.png +dump_console 11_canvas.console.txt + +goto crates/e2e/pages/12_svg.html +screenshot 12_svg.png diff --git a/crates/e2e/src/console.rs b/crates/e2e/src/console.rs new file mode 100644 index 0000000..7485215 --- /dev/null +++ b/crates/e2e/src/console.rs @@ -0,0 +1,41 @@ +//! Captured console output for headless tests. + +use std::cell::RefCell; +use std::rc::Rc; +use we_js::vm::ConsoleOutput; + +/// Captures `console.*` calls into an internal buffer instead of writing +/// to stdout/stderr. +#[derive(Default)] +pub struct CapturedConsole { + lines: RefCell>, +} + +impl CapturedConsole { + pub fn new() -> Self { + Self::default() + } + + pub fn lines(&self) -> Vec { + self.lines.borrow().clone() + } + + pub fn joined(&self) -> String { + self.lines.borrow().join("\n") + } +} + +/// Wrapper that adapts `Rc` to the `ConsoleOutput` trait. +pub struct RcConsole(pub Rc); + +impl ConsoleOutput for RcConsole { + fn log(&self, message: &str) { + self.0.lines.borrow_mut().push(format!("LOG {message}")); + } + fn warn(&self, message: &str) { + self.0.lines.borrow_mut().push(format!("WARN {message}")); + } + fn error(&self, message: &str) { + self.0.lines.borrow_mut().push(format!("ERR {message}")); + } +} diff --git a/crates/e2e/src/dom_dump.rs b/crates/e2e/src/dom_dump.rs new file mode 100644 index 0000000..5d2dbef --- /dev/null +++ b/crates/e2e/src/dom_dump.rs @@ -0,0 +1,68 @@ +//! Plain-text serialization of a Document for inspection and assertions. + +use we_dom::{Document, NodeData, NodeId}; + +/// Dump the document tree as indented text. Useful as a "what does the parser +/// think this page is" snapshot for regression tests. +pub fn dump(doc: &Document) -> String { + let mut out = String::new(); + walk(doc, doc.root(), 0, &mut out); + out +} + +fn walk(doc: &Document, node: NodeId, depth: usize, out: &mut String) { + for _ in 0..depth { + out.push_str(" "); + } + match doc.node_data(node) { + NodeData::Document => out.push_str("#document\n"), + NodeData::Element { tag_name, .. } => { + out.push('<'); + out.push_str(tag_name.as_str()); + if let Some(attrs) = doc.attributes(node) { + for a in attrs { + out.push(' '); + out.push_str(a.name.as_str()); + out.push_str("=\""); + for c in a.value.chars() { + match c { + '"' => out.push_str("""), + '\n' => out.push_str("\\n"), + _ => out.push(c), + } + } + out.push('"'); + } + } + out.push_str(">\n"); + } + NodeData::Text { data } => { + let trimmed = data.trim(); + if !trimmed.is_empty() { + out.push('"'); + for c in trimmed.chars().take(120) { + match c { + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + _ => out.push(c), + } + } + if trimmed.chars().count() > 120 { + out.push('…'); + } + out.push('"'); + out.push('\n'); + } else { + out.push_str("(whitespace)\n"); + } + } + NodeData::Comment { data } => { + out.push_str("\n"); + } + } + for child in doc.children(node) { + walk(doc, child, depth + 1, out); + } +} diff --git a/crates/e2e/src/lib.rs b/crates/e2e/src/lib.rs new file mode 100644 index 0000000..8187db6 --- /dev/null +++ b/crates/e2e/src/lib.rs @@ -0,0 +1,17 @@ +//! Headless rendering + scripted interaction harness for the `we` browser. +//! +//! Drives the same library pipeline the production binary uses, but without +//! AppKit. Loads a URL or file, runs scripts, lays out, renders to a software +//! pixel buffer, and produces: +//! - a PNG screenshot, +//! - a text dump of the rendered DOM, +//! - captured `console.*` output. +//! +//! Designed for crash detection and visual regression — not for performance +//! benchmarking (Metal path is intentionally bypassed). + +pub mod console; +pub mod dom_dump; +pub mod png_out; +pub mod render; +pub mod scenario; diff --git a/crates/e2e/src/main.rs b/crates/e2e/src/main.rs new file mode 100644 index 0000000..48d586e --- /dev/null +++ b/crates/e2e/src/main.rs @@ -0,0 +1,184 @@ +//! `we-e2e` — headless harness CLI. +//! +//! Two modes: +//! 1. Single-shot: render a URL and write a screenshot. +//! `we-e2e --url URL --out path.png [--width W --height H]` +//! 2. Scenario file: run a `.we` scenario. +//! `we-e2e --scenario path.we [--out-dir DIR]` + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let opts = match parse_args(&args) { + Ok(o) => o, + Err(e) => { + eprintln!("error: {e}"); + print_usage(); + return ExitCode::from(2); + } + }; + + match opts.mode { + Mode::SingleShot { url, out } => single_shot(&url, opts.width, opts.height, &out), + Mode::Scenario { path } => run_scenario(&path, &opts.out_dir), + } +} + +struct Opts { + width: u32, + height: u32, + mode: Mode, + out_dir: PathBuf, +} + +enum Mode { + SingleShot { url: String, out: PathBuf }, + Scenario { path: PathBuf }, +} + +fn parse_args(args: &[String]) -> Result { + let mut width: u32 = 800; + let mut height: u32 = 600; + let mut url: Option = None; + let mut out: Option = None; + let mut scenario: Option = None; + let mut out_dir: Option = None; + + let mut i = 0; + while i < args.len() { + let a = &args[i]; + match a.as_str() { + "--width" => { + i += 1; + width = args + .get(i) + .ok_or("--width: missing value")? + .parse() + .map_err(|_| "--width: not an integer")?; + } + "--height" => { + i += 1; + height = args + .get(i) + .ok_or("--height: missing value")? + .parse() + .map_err(|_| "--height: not an integer")?; + } + "--url" => { + i += 1; + url = Some(args.get(i).ok_or("--url: missing value")?.clone()); + } + "--out" => { + i += 1; + out = Some(PathBuf::from(args.get(i).ok_or("--out: missing value")?)); + } + "--scenario" => { + i += 1; + scenario = Some(PathBuf::from( + args.get(i).ok_or("--scenario: missing value")?, + )); + } + "--out-dir" => { + i += 1; + out_dir = Some(PathBuf::from( + args.get(i).ok_or("--out-dir: missing value")?, + )); + } + "-h" | "--help" => { + print_usage(); + std::process::exit(0); + } + other => return Err(format!("unknown arg: {other}")), + } + i += 1; + } + + let mode = match (url, out, scenario) { + (Some(u), Some(o), None) => Mode::SingleShot { url: u, out: o }, + (None, None, Some(p)) => Mode::Scenario { path: p }, + (Some(_), None, _) => return Err("--url requires --out".into()), + (None, Some(_), _) => return Err("--out requires --url".into()), + (Some(_), _, Some(_)) => return Err("--url and --scenario are mutually exclusive".into()), + (None, None, None) => return Err("specify --url/--out or --scenario".into()), + }; + + Ok(Opts { + width, + height, + mode, + out_dir: out_dir.unwrap_or_else(|| PathBuf::from(".")), + }) +} + +fn print_usage() { + eprintln!( + "usage:\n \ + we-e2e --url --out [--width W --height H]\n \ + we-e2e --scenario [--out-dir DIR]" + ); +} + +fn single_shot(url: &str, width: u32, height: u32, out: &Path) -> ExitCode { + match std::panic::catch_unwind(|| we_e2e::render::render(url, width, height)) { + Ok(Ok(o)) => { + let png = we_e2e::png_out::bgra_to_png(&o.bgra, o.width, o.height); + if let Some(parent) = out.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(out, &png) { + eprintln!("write {}: {}", out.display(), e); + return ExitCode::from(1); + } + for w in &o.warnings { + eprintln!("warn: {w}"); + } + println!("ok: {} ({}x{})", out.display(), o.width, o.height); + ExitCode::SUCCESS + } + Ok(Err(e)) => { + eprintln!("render error: {e}"); + ExitCode::from(1) + } + Err(_) => { + eprintln!("PANIC during render of {url}"); + ExitCode::from(101) + } + } +} + +fn run_scenario(path: &Path, out_dir: &Path) -> ExitCode { + let scenario = match we_e2e::scenario::parse(path) { + Ok(s) => s, + Err(e) => { + eprintln!("parse error: {e}"); + return ExitCode::from(2); + } + }; + let _ = std::fs::create_dir_all(out_dir); + + let report = match std::panic::catch_unwind(|| we_e2e::scenario::run(&scenario, out_dir)) { + Ok(r) => r, + Err(_) => { + eprintln!("PANIC during scenario {}", path.display()); + return ExitCode::from(101); + } + }; + + println!("scenario: {}", report.scenario.display()); + for (lineno, ok, msg) in &report.steps { + let tag = if *ok { "PASS" } else { "FAIL" }; + println!(" L{lineno:>3} {tag} {msg}"); + } + for w in &report.warnings { + println!(" WARN {w}"); + } + if report.passed { + println!("RESULT: PASS"); + ExitCode::SUCCESS + } else { + println!("RESULT: FAIL"); + ExitCode::from(1) + } +} diff --git a/crates/e2e/src/png_out.rs b/crates/e2e/src/png_out.rs new file mode 100644 index 0000000..abc7812 --- /dev/null +++ b/crates/e2e/src/png_out.rs @@ -0,0 +1,15 @@ +//! Convert the renderer's BGRA buffer to PNG bytes. + +use we_image::png::encode_png_rgba; + +/// Encode a BGRA8 buffer as PNG. Swaps B and R in-place into a copy. +/// +/// The software renderer stores pixels as BGRA (matching CoreGraphics on +/// little-endian), but the PNG encoder expects RGBA. +pub fn bgra_to_png(bgra: &[u8], width: u32, height: u32) -> Vec { + let mut rgba = bgra.to_vec(); + for px in rgba.chunks_exact_mut(4) { + px.swap(0, 2); + } + encode_png_rgba(&rgba, width, height) +} diff --git a/crates/e2e/src/render.rs b/crates/e2e/src/render.rs new file mode 100644 index 0000000..4071562 --- /dev/null +++ b/crates/e2e/src/render.rs @@ -0,0 +1,376 @@ +//! Headless load + render pipeline. +//! +//! Mirrors `crates/browser/src/main.rs::load_page` and the relevant slice of +//! `render_page`, but without any AppKit/Metal dependency. Output is a raw +//! BGRA8 buffer plus the artifacts a test wants to assert against. + +use std::collections::HashMap; +use std::rc::Rc; + +use we_browser::csp::{self, PolicyList}; +use we_browser::css_loader::collect_stylesheets; +use we_browser::font_loader::load_web_fonts; +use we_browser::img_loader::{collect_images, ImageStore}; +use we_browser::loader::{Resource, ResourceLoader, ABOUT_BLANK_HTML}; +use we_browser::script_loader::execute_scripts_into_vm_with_console; +use we_css::parser::Stylesheet; +use we_dom::{Document, NodeData, NodeId}; +use we_html::parse_html; +use we_image::pixel::Image; +use we_layout::layout; +use we_render::{build_display_list_with_page_scroll, Renderer}; +use we_style::computed::resolve_styles; +use we_svg::{render_svg, svg_intrinsic_size}; +use we_text::font::{self, Font, FontRegistry}; +use we_url::Url; + +use crate::console::{CapturedConsole, RcConsole}; + +/// Everything a headless render produces. +pub struct RenderOutput { + /// Raw BGRA8 framebuffer (top-down). Width * height * 4 bytes. + pub bgra: Vec, + pub width: u32, + pub height: u32, + /// Final document tree after script execution. + pub doc: Document, + /// Captured console output. + pub console: Rc, + /// Final URL (after resolving file/URL arg). + pub base_url: Url, + /// Any non-fatal load errors collected during page setup. + pub warnings: Vec, +} + +/// Render `arg` (URL, file path, or `about:blank`) at the given viewport size. +/// +/// The viewport excludes any chrome (no chrome is rendered in headless mode). +pub fn render(arg: &str, width: u32, height: u32) -> Result { + let loaded = load_from_arg(arg)?; + render_loaded(loaded, width, height) +} + +fn render_loaded(loaded: LoadedHtml, width: u32, height: u32) -> Result { + let mut warnings = Vec::new(); + + // Parse + execute scripts + load subresources. + let console = Rc::new(CapturedConsole::new()); + let (doc, stylesheet, images, font_registry, base_url) = + load_page(loaded, Rc::clone(&console), &mut warnings); + + // Pick a font. Prefer a web font, else system font, else fail. + let font = font_registry + .find_best_font() + .or_else(|| font::load_system_font().ok()) + .ok_or_else(|| "no font available for rendering".to_string())?; + + // Style and layout. + let viewport_w = width as f32; + let viewport_h = height as f32; + let styled = resolve_styles( + &doc, + std::slice::from_ref(&stylesheet), + (viewport_w, viewport_h), + ) + .ok_or_else(|| "style resolution failed".to_string())?; + + let mut sizes = image_sizes(&images); + let svg_sizes = collect_svg_sizes(&doc); + sizes.extend(svg_sizes); + we_browser::iframe_loader::collect_iframe_sizes(&doc, &mut sizes); + + let canvas_images = collect_canvas_images(&doc); + let svg_images = rasterize_svgs(&doc, &font); + let mut refs: HashMap = HashMap::new(); + for (id, store_entry) in &images { + if let Some(img) = &store_entry.image { + refs.insert(*id, img); + } + } + for (id, img) in &svg_images { + refs.insert(*id, img); + } + for (id, img) in &canvas_images { + refs.insert(*id, img); + } + + let tree = layout(&styled, &doc, viewport_w, viewport_h, &font, &sizes); + let display_list = build_display_list_with_page_scroll(&tree, 0.0, &HashMap::new()); + + let mut renderer = Renderer::new(width, height); + renderer.paint_display_list(&display_list, &font, &refs); + let bgra = renderer.pixels().to_vec(); + + Ok(RenderOutput { + bgra, + width, + height, + doc, + console, + base_url, + warnings, + }) +} + +// --------------------------------------------------------------------------- +// Internal: copy of `main.rs::load_from_arg` / `load_page`, kept minimal. +// --------------------------------------------------------------------------- + +struct LoadedHtml { + text: String, + base_url: Url, + http_csp: PolicyList, +} + +fn load_from_arg(arg: &str) -> Result { + if arg.starts_with("http://") + || arg.starts_with("https://") + || arg.starts_with("about:") + || arg.starts_with("data:") + { + let mut loader = ResourceLoader::new(); + match loader.fetch_url(arg, None) { + Ok(resource) => Ok(resource_to_loaded(resource)), + Err(e) => Err(format!("fetch {arg}: {e:?}")), + } + } else if arg == "about:blank" || arg.is_empty() { + Ok(LoadedHtml { + text: ABOUT_BLANK_HTML.to_string(), + base_url: Url::parse("about:blank").expect("about:blank"), + http_csp: PolicyList::new(), + }) + } else { + // File path. + let text = std::fs::read_to_string(arg).map_err(|e| format!("read {arg}: {e}"))?; + let abs = std::fs::canonicalize(arg).unwrap_or_else(|_| std::path::PathBuf::from(arg)); + let base = Url::parse(&format!("file://{}", abs.display())) + .unwrap_or_else(|_| Url::parse("about:blank").unwrap()); + Ok(LoadedHtml { + text, + base_url: base, + http_csp: PolicyList::new(), + }) + } +} + +fn resource_to_loaded(resource: Resource) -> LoadedHtml { + match resource { + Resource::Html { text, base_url, .. } => LoadedHtml { + text, + base_url, + http_csp: PolicyList::new(), + }, + Resource::Css { text, url } => LoadedHtml { + text: format!("
{}
", escape(&text)), + base_url: url, + http_csp: PolicyList::new(), + }, + Resource::Script { text, url } => LoadedHtml { + text: format!("
{}
", escape(&text)), + base_url: url, + http_csp: PolicyList::new(), + }, + Resource::Image { mime_type, url, .. } => LoadedHtml { + text: format!("

Image: {mime_type}

"), + base_url: url, + http_csp: PolicyList::new(), + }, + Resource::Other { + data, + mime_type, + url, + } => { + if mime_type.starts_with("text/") { + let text = String::from_utf8_lossy(&data).into_owned(); + LoadedHtml { + text: format!("
{}
", escape(&text)), + base_url: url, + http_csp: PolicyList::new(), + } + } else { + LoadedHtml { + text: format!("

Unsupported MIME type: {mime_type}

"), + base_url: url, + http_csp: PolicyList::new(), + } + } + } + } +} + +fn escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn load_page( + loaded: LoadedHtml, + console: Rc, + warnings: &mut Vec, +) -> (Document, Stylesheet, ImageStore, FontRegistry, Url) { + let doc = parse_html(&loaded.text); + + let meta_csp = csp::extract_meta_csp(&doc); + let mut csp_policies = loaded.http_csp; + for policy in meta_csp.policies { + csp_policies.add(policy); + } + + let mut loader = ResourceLoader::new(); + loader.set_document_url(&loaded.base_url); + loader.set_csp(csp_policies); + + let document_origin = loaded.base_url.origin(); + let mut vm = execute_scripts_into_vm_with_console( + doc, + &mut loader, + &loaded.base_url, + &document_origin, + Some(Box::new(RcConsole(Rc::clone(&console)))), + ); + + let (stylesheet, font_registry, images) = { + let doc_ref = vm + .borrow_document() + .expect("document still attached after scripts"); + + let stylesheet = collect_stylesheets(&doc_ref, &mut loader, &loaded.base_url); + + let mut font_registry = FontRegistry::new(); + let fr = load_web_fonts( + &stylesheet, + &mut loader, + &loaded.base_url, + &mut font_registry, + ); + if fr.failed > 0 { + warnings.push(format!("{} web font(s) failed to load", fr.failed)); + } + + let images = collect_images(&doc_ref, &mut loader, &loaded.base_url); + + (stylesheet, font_registry, images) + }; + + // Push decoded image data into the document so canvas/drawImage works. + vm.with_document_mut(|doc| { + for (node_id, resource) in &images { + if let Some(img) = &resource.image { + doc.store_image_data(*node_id, img.data.clone(), img.width, img.height); + } + } + }); + + vm.set_ready_state("complete"); + we_js::dom_bridge::fire_lifecycle_event( + &mut vm, + "load", + we_js::dom_bridge::LifecycleTarget::Window, + false, + false, + ); + let _ = vm.pump_event_loop(); + + we_js::fetch::clear_document_origin(); + let doc = vm.detach_document().unwrap_or_default(); + + (doc, stylesheet, images, font_registry, loaded.base_url) +} + +// --------------------------------------------------------------------------- +// Helpers copied from main.rs (image/svg/canvas glue for the layout/render +// pipeline). Kept here so the headless harness doesn't depend on internals +// of the binary crate. +// --------------------------------------------------------------------------- + +fn image_sizes(store: &ImageStore) -> HashMap { + let mut m = HashMap::new(); + for (id, r) in store { + if r.display_width > 0.0 || r.display_height > 0.0 { + m.insert(*id, (r.display_width, r.display_height)); + } + } + m +} + +fn collect_svg_sizes(doc: &Document) -> HashMap { + let mut out = HashMap::new(); + collect_svg_nodes(doc, doc.root(), &mut out); + out +} + +fn collect_svg_nodes(doc: &Document, node: NodeId, out: &mut HashMap) { + if let NodeData::Element { + ref tag_name, + ref namespace, + .. + } = *doc.node_data(node) + { + if tag_name.as_str() == "svg" + && namespace.as_ref().map(|n| n.as_str()) == Some("http://www.w3.org/2000/svg") + { + if let Some((w, h)) = svg_intrinsic_size(doc, node) { + out.insert(node, (w, h)); + } + return; + } + } + for child in doc.children(node) { + collect_svg_nodes(doc, child, out); + } +} + +fn rasterize_svgs(doc: &Document, font: &Font) -> HashMap { + let mut out = HashMap::new(); + collect_and_rasterize(doc, doc.root(), font, &mut out); + out +} + +fn collect_and_rasterize( + doc: &Document, + node: NodeId, + font: &Font, + out: &mut HashMap, +) { + if let NodeData::Element { + ref tag_name, + ref namespace, + .. + } = *doc.node_data(node) + { + if tag_name.as_str() == "svg" + && namespace.as_ref().map(|n| n.as_str()) == Some("http://www.w3.org/2000/svg") + { + if let Some((w, h, data)) = render_svg(doc, node, Some(font)) { + out.insert( + node, + Image { + width: w, + height: h, + data, + }, + ); + } + return; + } + } + for child in doc.children(node) { + collect_and_rasterize(doc, child, font, out); + } +} + +fn collect_canvas_images(doc: &Document) -> HashMap { + let mut out = HashMap::new(); + for (node_id, (w, h)) in doc.canvas_entries() { + if w == 0 || h == 0 { + continue; + } + if let Some(buf) = doc.canvas_buffer(node_id) { + if let Ok(img) = Image::new(w, h, buf.to_vec()) { + out.insert(node_id, img); + } + } + } + out +} diff --git a/crates/e2e/src/scenario.rs b/crates/e2e/src/scenario.rs new file mode 100644 index 0000000..d991476 --- /dev/null +++ b/crates/e2e/src/scenario.rs @@ -0,0 +1,268 @@ +//! Minimal scenario DSL. +//! +//! Line-based, one command per line. `#` starts a comment, blank lines ignored. +//! +//! Commands (MVP): +//! +//! ```text +//! goto # load a page (replaces any prior state) +//! viewport # set viewport before next goto (default 800x600) +//! screenshot # write current screenshot +//! dump_dom # write DOM tree dump +//! 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) +//! ``` +//! +//! Interaction commands (click, type, etc.) will be added in a follow-up. +//! For now the harness gives us crash detection + visual snapshots, which is +//! enough to surface a large class of bugs. + +use std::path::{Path, PathBuf}; + +use crate::render::{render, RenderOutput}; + +#[derive(Debug, Clone)] +pub enum Cmd { + Goto(String), + Viewport(u32, u32), + Screenshot(PathBuf), + DumpDom(PathBuf), + DumpConsole(PathBuf), + AssertDomContains(String), + AssertConsoleContains(String), + ExpectNoPanics, +} + +pub struct Scenario { + pub source: PathBuf, + pub commands: Vec<(usize, Cmd)>, +} + +pub fn parse(source: &Path) -> Result { + let text = std::fs::read_to_string(source).map_err(|e| format!("read {source:?}: {e}"))?; + let mut commands = Vec::new(); + for (lineno_0, raw) in text.lines().enumerate() { + let lineno = lineno_0 + 1; + let line = raw.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + let cmd = + parse_line(line).map_err(|e| format!("{}:{}: {}", source.display(), lineno, e))?; + commands.push((lineno, cmd)); + } + Ok(Scenario { + source: source.to_path_buf(), + commands, + }) +} + +fn parse_line(line: &str) -> Result { + let mut parts = line.splitn(2, char::is_whitespace); + let head = parts.next().unwrap_or(""); + let rest = parts.next().unwrap_or("").trim(); + match head { + "goto" => { + if rest.is_empty() { + return Err("goto: missing URL".into()); + } + Ok(Cmd::Goto(rest.to_string())) + } + "viewport" => { + let mut it = rest.split_whitespace(); + let w: u32 = it + .next() + .ok_or("viewport: missing width")? + .parse() + .map_err(|_| "viewport: width not an integer")?; + let h: u32 = it + .next() + .ok_or("viewport: missing height")? + .parse() + .map_err(|_| "viewport: height not an integer")?; + Ok(Cmd::Viewport(w, h)) + } + "screenshot" => { + if rest.is_empty() { + return Err("screenshot: missing path".into()); + } + Ok(Cmd::Screenshot(PathBuf::from(rest))) + } + "dump_dom" => { + if rest.is_empty() { + return Err("dump_dom: missing path".into()); + } + Ok(Cmd::DumpDom(PathBuf::from(rest))) + } + "dump_console" => { + if rest.is_empty() { + return Err("dump_console: missing path".into()); + } + Ok(Cmd::DumpConsole(PathBuf::from(rest))) + } + "assert_dom_contains" => Ok(Cmd::AssertDomContains(unquote(rest))), + "assert_console_contains" => Ok(Cmd::AssertConsoleContains(unquote(rest))), + "expect_no_panics" => Ok(Cmd::ExpectNoPanics), + other => Err(format!("unknown command: {other:?}")), + } +} + +fn unquote(s: &str) -> String { + let s = s.trim(); + if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +pub struct RunReport { + pub scenario: PathBuf, + pub passed: bool, + /// Each (line, ok, message) entry. + pub steps: Vec<(usize, bool, String)>, + /// Non-fatal warnings collected while loading the page. + pub warnings: Vec, +} + +/// Execute a scenario. Paths in `dump_*`/`screenshot` are resolved relative +/// to `out_dir`. Returns a `RunReport` describing the run. +pub fn run(scenario: &Scenario, out_dir: &Path) -> RunReport { + let mut viewport = (800u32, 600u32); + let mut current: Option = None; + let mut steps: Vec<(usize, bool, String)> = Vec::new(); + let mut all_ok = true; + let mut warnings = Vec::new(); + + for (lineno, cmd) in &scenario.commands { + match cmd { + 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}"))); + all_ok = false; + current = None; + } + }, + Cmd::Viewport(w, h) => { + viewport = (*w, *h); + steps.push((*lineno, true, format!("viewport {w}x{h}"))); + } + 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; + } + } + } + None => { + steps.push((*lineno, false, "screenshot: no page loaded".into())); + all_ok = false; + } + }, + 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; + } + } + } + None => { + steps.push((*lineno, false, "dump_dom: no page loaded".into())); + all_ok = false; + } + }, + 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; + } + } + } + None => { + steps.push((*lineno, false, "dump_console: no page loaded".into())); + all_ok = false; + } + }, + 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; + } + } + None => { + steps.push((*lineno, false, "assert_dom_contains: no page".into())); + all_ok = false; + } + }, + 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; + } + } + None => { + steps.push((*lineno, false, "assert_console_contains: no page".into())); + all_ok = false; + } + }, + Cmd::ExpectNoPanics => { + steps.push((*lineno, true, "expect_no_panics".into())); + } + } + } + + RunReport { + scenario: scenario.source.clone(), + passed: all_ok, + steps, + warnings, + } +} diff --git a/crates/text/src/rasterizer.rs b/crates/text/src/rasterizer.rs new file mode 100644 index 0000000..9865924 --- /dev/null +++ b/crates/text/src/rasterizer.rs @@ -0,0 +1,287 @@ +use crate::font::tables::glyf::{GlyphOutline, Point}; + +/// A rasterized grayscale bitmap of a glyph. +#[derive(Debug, Clone, PartialEq)] +pub struct GlyphBitmap { + pub width: u32, + pub height: u32, + /// X offset from the origin (in pixels) for the left edge. + pub bearing_x: i32, + /// Y offset from the origin (in pixels) for the top edge. + /// Y axis points up, so positive bearing_y means the image goes above the baseline. + pub bearing_y: i32, + /// Grayscale coverage data, 0 (transparent) to 255 (opaque). + /// Size is `width * height`. + pub data: Vec, +} + +enum Op { + MoveTo((f32, f32)), + LineTo((f32, f32)), + QuadTo((f32, f32), (f32, f32)), +} + +struct Line { + x0: f32, + y0: f32, + x1: f32, + y1: f32, +} + +struct Intersection { + x: f32, + dir: i32, +} + +/// Convert an outline (font units) into a grayscale bitmap at the given scale limit. +pub fn rasterize(outline: &GlyphOutline, scale: f32) -> Option { + if outline.contours.is_empty() { + return None; + } + + // Determine dimensions in pixels. + let x_min = outline.x_min as f32; + let y_min = outline.y_min as f32; + let x_max = outline.x_max as f32; + let y_max = outline.y_max as f32; + + let width_px = ((x_max - x_min) * scale).ceil() as u32; + let height_px = ((y_max - y_min) * scale).ceil() as u32; + + if width_px == 0 || height_px == 0 { + return None; + } + + // Font Y is UP. Pixel Y is DOWN. + // Transform maps (x, y) from font units to raster coords: + // x_px = (x - x_min) * scale + // y_px = (y_max - y) * scale + let transform = |p: Point| -> (f32, f32) { + let px = (p.x as f32 - x_min) * scale; + let py = (y_max - p.y as f32) * scale; + (px, py) + }; + + let mut ops = Vec::new(); + + // 1. Process contours to extract normalized drawing commands + for contour in &outline.contours { + let points = &contour.points; + if points.is_empty() { + continue; + } + + // TrueType encoding implicitly adds an on-curve point between two consecutive + // off-curve points. + let mut curve_pts = Vec::with_capacity(points.len() * 2); + let n = points.len(); + for i in 0..n { + let curr = points[i]; + curve_pts.push(curr); + + let next = points[(i + 1) % n]; + if !curr.on_curve && !next.on_curve { + curve_pts.push(Point { + x: ((curr.x as i32 + next.x as i32) / 2) as i16, + y: ((curr.y as i32 + next.y as i32) / 2) as i16, + on_curve: true, + }); + } + } + + // Contour must start with an on-curve point for easy processing. + let start_idx = curve_pts.iter().position(|p| p.on_curve).unwrap_or(0); + if start_idx != 0 { + curve_pts.rotate_left(start_idx); + // If the whole contour was off-curve (rare/invalid), add an on-curve point. + if !curve_pts[0].on_curve { + let curr = curve_pts[0]; + let next = curve_pts[1]; + let mid = Point { + x: ((curr.x as i32 + next.x as i32) / 2) as i16, + y: ((curr.y as i32 + next.y as i32) / 2) as i16, + on_curve: true, + }; + curve_pts.insert(0, mid); + } + } + + let mut i = 0; + ops.push(Op::MoveTo(transform(curve_pts[0]))); + let m = curve_pts.len(); + + while i < m { + let p1 = curve_pts[(i + 1) % m]; + if p1.on_curve { + ops.push(Op::LineTo(transform(p1))); + i += 1; + } else { + let p2 = curve_pts[(i + 2) % m]; + // In properly resolved TrueType contours, if p1 is off-curve, p2 must be on-curve. + ops.push(Op::QuadTo(transform(p1), transform(p2))); + i += 2; + } + } + } + + // 2. Flatten curves into line segments + let mut lines = Vec::new(); + let mut curr = (0.0, 0.0); + // 0.25 px squared is very fine visually + let tolerance_sq = 0.25; + + for op in ops { + match op { + Op::MoveTo(p) => curr = p, + Op::LineTo(p) => { + lines.push(Line { + x0: curr.0, + y0: curr.1, + x1: p.0, + y1: p.1, + }); + curr = p; + } + Op::QuadTo(ctrl, end) => { + flatten_quadratic(curr, ctrl, end, &mut lines, tolerance_sq); + curr = end; + } + } + } + + // Filter out horizontal or zero-length lines to avoid divide-by-zero + // and because they don't cross vertical scanlines anyway. + lines.retain(|l| l.y0 != l.y1); + + // 3. Scanline Rasterization with 16x vertical supersampling + let samples = 16_usize; + let sample_weight = 1.0 / (samples as f32); + let mut coverage = vec![0.0f32; (width_px * height_px) as usize]; + + for row in 0..height_px { + let row_offset = (row * width_px) as usize; + + for s in 0..samples { + let y = row as f32 + (s as f32 + 0.5) * sample_weight; + let mut hits = Vec::new(); + + for line in &lines { + let y_min = f32::min(line.y0, line.y1); + let y_max = f32::max(line.y0, line.y1); + + if y >= y_min && y < y_max { + let dir = if line.y1 > line.y0 { 1 } else { -1 }; + let t = (y - line.y0) / (line.y1 - line.y0); + let x = line.x0 + (line.x1 - line.x0) * t; + hits.push(Intersection { x, dir }); + } + } + + if hits.is_empty() { + continue; + } + + hits.sort_unstable_by(|a, b| a.x.partial_cmp(&b.x).unwrap()); + + // Non-zero winding fill + let mut winding = 0; + let mut last_x = 0.0; + + for hit in hits { + if winding != 0 { + let x_start = f32::max(0.0, last_x); + let x_end = f32::min(width_px as f32, hit.x); + + if x_start < x_end { + add_coverage( + &mut coverage, + width_px as usize, + row_offset, + x_start, + x_end, + sample_weight, + ); + } + } + winding += hit.dir; + last_x = hit.x; + } + } + } + + // 4. Convert float coverage to u8 + let mut data = Vec::with_capacity(coverage.len()); + for val in coverage { + let alpha = (val.clamp(0.0, 1.0) * 255.0).round() as u8; + data.push(alpha); + } + + Some(GlyphBitmap { + width: width_px, + height: height_px, + bearing_x: (x_min * scale).floor() as i32, + // Because Y points up in fonts, offset from baseline is simply max_y scaled + bearing_y: (y_max * scale).ceil() as i32, + data, + }) +} + +// Recursively split a quadratic Bézier curve. +fn flatten_quadratic( + p0: (f32, f32), + p1: (f32, f32), + p2: (f32, f32), + lines: &mut Vec, + tolerance_sq: f32, +) { + let dx = p0.0 - 2.0 * p1.0 + p2.0; + let dy = p0.1 - 2.0 * p1.1 + p2.1; + let dist_sq = dx * dx + dy * dy; + + if dist_sq <= tolerance_sq { + lines.push(Line { + x0: p0.0, + y0: p0.1, + x1: p2.0, + y1: p2.1, + }); + } else { + let p01 = ((p0.0 + p1.0) / 2.0, (p0.1 + p1.1) / 2.0); + let p12 = ((p1.0 + p2.0) / 2.0, (p1.1 + p2.1) / 2.0); + let p012 = ((p01.0 + p12.0) / 2.0, (p01.1 + p12.1) / 2.0); + + flatten_quadratic(p0, p01, p012, lines, tolerance_sq); + flatten_quadratic(p012, p12, p2, lines, tolerance_sq); + } +} + +// Add partial coverage for the horizontal segment [x_start, x_end]. +fn add_coverage( + buffer: &mut [f32], + width: usize, + row_offset: usize, + x_start: f32, + x_end: f32, + amount: f32, +) { + let start_col = x_start.floor() as usize; + let end_col = x_end.floor() as usize; + + if start_col == end_col { + if start_col < width { + buffer[row_offset + start_col] += (x_end - x_start) * amount; + } + } else { + if start_col < width { + buffer[row_offset + start_col] += ((start_col + 1) as f32 - x_start) * amount; + } + for col in start_col + 1..end_col { + if col < width { + buffer[row_offset + col] += amount; + } + } + if end_col < width { + buffer[row_offset + end_col] += (x_end - end_col as f32) * amount; + } + } +}