Every UI-verification post in this series up to now has ended the same way: bring the real window to the foreground, screenshot it, and if a click was needed, fire a synthetic user32.dll mouse_event at hardcoded on-screen coordinates. It works, but it's fragile in exactly the way you'd expect - the kanban post hit a case where an earlier click's reflow moved a header out from under a second click aimed at precomputed coordinates, and the stylus post documented that synthetic keyboard injection doesn't even generate a WindowEvent::KeyboardInput in this environment at all.
This post replaces that approach for one addon with a real cucumber-based BDD suite wired into cargo test: fast Gherkin scenarios that exercise pure browser-history logic in milliseconds, and a slower live tier that launches the actual windowed html-ui-demo binary, drives it with the exact widget-level events a real click or keystroke would produce, and captures composited PNG screenshots as evidence - no coordinates, no OS input simulation, anywhere in the loop. It's also the reason this post comes before the next one: the browser-feature upgrades in the following post are verified by this suite, not by more screenshots-and-coordinates.
What we're building
tests/features/browser_loop.feature(@fast) - three Gherkin scenarios (follow/back/forward history, bookmarking, a failed fetch) run against a pure-RustBrowserWorldmodel, no window involved.tests/features/browser_live.feature(@live-ui) - the actual source of the live tier's action sequence:src/startup.rsparses this file with thegherkincrate at startup, rather than a hand-written list that merely resembles it.tests/browser_bdd.rs- aharness = falsecustom test binary ([[test]] name = "browser_bdd"inCargo.toml) that runs the fast Gherkin suite first, then spawns the realexamplebinary in a scripted test mode and asserts on its result.BrowserBddDriver(src/startup.rs) - anENTROPY_BROWSER_BDD_RESULT-gated action queue that injects real widget events frame-by-frame into the running app and requests screenshots at specific points.EntropyPipeline::request_ui_screenshot/pending_ui_screenshot(src/core/pipeline.rs) - a new capture path that grabs the fully composited frame (scene and entropy_gui), after the existing scene-only video-export capture path.- Stable, caller-supplied widget IDs threaded through
Entropy.UI.Widget.html's newid/onLinkClickoptions andop_ui_render_html, so a driver script has something fixed to target.
The work
Two tiers, not one
tests/browser_bdd.rs defines a small BrowserWorld - a Vec<String> history, an index, a bookmark list, an optional fetch error - and Gherkin step definitions (#[given], #[when], #[then]) that operate on it directly:
#[given(expr = "the browser has loaded {string}")]
fn browser_has_loaded(world: &mut BrowserWorld, url: String) {
world.loaded(url);
}
#[when("the browser goes back")]
fn browser_goes_back(world: &mut BrowserWorld) {
if let Some(index) = world.history_index.filter(|index| *index > 0) {
world.history_index = Some(index - 1);
}
}This runs in-process, no window, no GPU, in well under a second - cucumber::World::run("tests/features/browser_loop.feature") inside #[tokio::main] async fn main(). It's a model of the addon's history/bookmark logic, not the addon itself, which is the honest way to describe it: it proves the rules ("going back after going forward returns to the earlier URL") are self-consistent, not that the real TypeScript implementing those rules follows them.
Immediately after that suite passes, the same binary launches the real compiled example:
let example = std::env::var_os("CARGO_BIN_EXE_example")
.expect("Cargo must provide the real example binary to browser_bdd");
let status = Command::new(example)
.arg("html-ui-demo")
.env("ENTROPY_BROWSER_BDD_RESULT", &result_path)
.status()
.expect("launch the real Entropy HTML browser demo");
assert!(status.success(), "the live browser demo must exit cleanly: {status}");CARGO_BIN_EXE_example is Cargo's own mechanism for handing a test binary the path to a sibling binary target it depends on - no manual path-guessing into target/debug/. Setting ENTROPY_BROWSER_BDD_RESULT is what turns the scripted driver on inside that process; without it, html-ui-demo runs exactly as a human would launch it.
Real events, not real clicks
BrowserBddDriver lives entirely inside src/startup.rs, behind an explicit environment-variable gate:
struct BrowserBddDriver {
actions: VecDeque<BrowserBddAction>,
artifacts: Vec<String>,
outcomes: Vec<serde_json::Value>,
artifact_dir: PathBuf,
result_path: PathBuf,
started: Instant,
}
enum BrowserBddAction {
Wait(u32),
Event { control_id: String, value: Option<String> },
Link { control_id: String, url: String },
Capture(String),
Finish,
}Application::about_to_wait ticks the driver once per frame, ahead of the normal gamepad/render loop, and each action either waits a frame, injects an event, requests a capture, or finishes. Injection is the part worth reading closely:
fn queue_event(window: &mut WindowState, event: String) {
if let Some(editor) = window.pipeline.export_editor.as_mut() {
let op_state = editor.addon_engine.runtime.op_state();
let op_state = op_state.borrow();
if let Some(context) = op_state.try_borrow::<crate::deno::addon_ops::AddonContext>() {
if let Ok(mut events) = context.ui_events.lock() {
events.push(event);
}
}
}
}AddonContext.ui_events is Arc<Mutex<Vec<String>>> - the exact queue a real button click extends into (addon_engine.rs's widget-render pass pushes a clicked button's id into this same vec, then the JS side's dispatch loop drains it and calls the matching onClick). The driver pushing "browser-go" into that vec is not a simulation of a click; from the addon's perspective it is byte-for-byte the same event a real click on the real button produces. A text input's change event is "browser-url|https://example.com" - control id, pipe, new value - which is exactly what the real textInput widget emits on every keystroke's onChange, so the driver's Event { control_id: "browser-url", value: Some("https://example.com") } reproduces one already-valid wire format rather than inventing a new one just for tests. A followed link is the same trick: "HTML_LINK|browser-page|https://www.iana.org/domains/example", the identical string addon_engine.rs's HTML-canvas render arm pushes when a real <a> inside a rendered page is actually clicked.
The feature file is the source, not a description of it
The obvious way to build the above is a hardcoded VecDeque of actions, hand-written to match whatever browser_live.feature says in prose. That was this feature's first version, and it's a trap: nothing then stops the two from drifting apart the first time either one gets edited alone. The actual implementation instead parses the .feature file itself, with the gherkin crate cucumber already depends on, and turns its steps directly into the same BrowserBddAction values:
const BROWSER_LIVE_FEATURE_SOURCE: &str = include_str!("../tests/features/browser_live.feature");
fn browser_bdd_actions_from_feature() -> VecDeque<BrowserBddAction> {
let feature = gherkin::Feature::parse(BROWSER_LIVE_FEATURE_SOURCE, gherkin::GherkinEnv::default())
.expect("tests/features/browser_live.feature must be valid Gherkin");
let mut actions: VecDeque<BrowserBddAction> = feature
.scenarios
.iter()
.flat_map(|scenario| scenario.steps.iter())
.filter_map(|step| browser_bdd_action_from_step(&step.value))
.collect();
actions.push_back(BrowserBddAction::Finish);
actions
}step.value is the step's text with its Given/When/Then/And keyword already stripped by gherkin's parser - I click "browser-go", not When I click "browser-go". browser_bdd_action_from_step matches a handful of fixed shapes against that text and extracts their quoted arguments by splitting on " rather than pulling in a second parsing crate just for this:
fn browser_bdd_action_from_step(text: &str) -> Option<BrowserBddAction> {
if text == "the real browser demo is running in test mode" {
return None;
}
let quoted: Vec<&str> = text.split('"').skip(1).step_by(2).collect();
if let Some(rest) = text.strip_prefix("I advance ") {
let count = rest.trim_end_matches(" frames").trim_end_matches(" frame")
.parse::<u32>()
.unwrap_or_else(|_| panic!("browser_live.feature: not a frame count in {text:?}"));
return Some(BrowserBddAction::Wait(count));
}
if text.starts_with("I set ") && quoted.len() == 2 {
return Some(BrowserBddAction::Event { control_id: quoted[0].to_string(), value: Some(quoted[1].to_string()) });
}
if text.starts_with("I click ") && quoted.len() == 1 {
return Some(BrowserBddAction::Event { control_id: quoted[0].to_string(), value: None });
}
if text.starts_with("I capture ") && quoted.len() == 1 {
return Some(BrowserBddAction::Capture(quoted[0].to_string()));
}
if text.starts_with("I follow the HTML link through ") && quoted.len() == 2 {
return Some(BrowserBddAction::Link { control_id: quoted[0].to_string(), url: quoted[1].to_string() });
}
panic!("browser_live.feature: no driver action recognized for step {text:?}");
}Two details worth calling out. First, include_str! rather than a runtime std::fs::read_to_string: it makes cargo track the .feature file as a build dependency (edit it, rebuild, the new content is what runs) without needing to resolve a path relative to whatever directory the process happens to be launched from - the live tier is a subprocess launched by tests/browser_bdd.rs, so "current directory" isn't something to lean on. Second, the opening Given the real browser demo is running in test mode step is recognized and deliberately maps to None - a real precondition with nothing for the driver to do - while every other unrecognized string panics rather than being silently skipped. That distinction is the entire point: a typo in the feature file has to fail loudly, or parsing it would be theater. Confirmed by deliberately inserting a garbage step and rerunning: thread 'main' panicked at src\startup.rs:...: browser_live.feature: no driver action recognized for step "I deliberately typo a step to verify the driver panics on it", which correctly failed tests/browser_bdd.rs too (the live browser demo must exit cleanly: exit code: 101) - not something left to trust blind, actually triggered and observed this session, then reverted.
The feature file itself now reads as a real spec, not prose paraphrasing a separate implementation:
Scenario: Load example.com
Given the real browser demo is running in test mode
When I click "browser-mode-webpage"
And I advance 2 frames
And I set "browser-url" to "https://example.com"
And I advance 1 frames
And I click "browser-go"
And I advance 120 frames
Then I capture "loading-example"The stable-ID gap this actually needed
None of the above works unless a control's id is the same thing every frame. Buttons and text inputs already supported a caller-supplied id (nextWidgetId(windowId, label, explicitId) has taken an optional explicit id for a while, across every widget in this kit). Entropy.UI.Widget.html didn't - before this session, its rendered canvas got an id derived from a live per-frame counter:
// before
let id = format!("html_canvas_{}", ctx.ui_widgets.get(&window_id).map(|v| v.len()).unwrap_or(0));That's fine for rendering (nothing outside this function ever needed to name the canvas), but useless as a target for a script that has to mean "the rendered page," full stop, regardless of how many other widgets happened to render before it that frame. The fix threads a real caller-supplied id through the op instead:
pub fn op_ui_render_html(
state: &mut OpState,
#[string] window_id: String,
#[string] html: String,
#[string] base_url: String,
width: f32,
handle_links: bool,
#[string] id: String,
) {
// ...
ctx.ui_widgets.entry(window_id).or_default().push(UiWidget::LayoutCanvas { id, width, height, boxes, handle_links });
}and the demo addon now passes { id: "browser-page", ... } explicitly. The browser controls (browser-back, browser-forward, browser-url, browser-go, browser-bookmark, browser-mode-markup, browser-mode-webpage) got the same treatment in html_ui_demo_addon.ts - not a new mechanism, just this addon actually using the existing one everywhere the driver needs to reach.
Capturing the composited frame, not just the scene
Entropy already had a screenshot path - the video exporter's FrameCaptureBuffer - but it captures before entropy_gui is composited onto the frame, which is correct for exporting a scene video and useless for proving a button and a rendered <h1> are both actually on screen together. request_ui_screenshot is a second, narrower capture:
pub fn request_ui_screenshot(&mut self, path: impl Into<std::path::PathBuf>) -> Result<(), String> {
let gpu = self.gpu_resources.as_ref().ok_or("GPU resources are not initialized")?;
let editor = self.export_editor.as_ref().ok_or("editor is not initialized")?;
let camera = editor.camera.as_ref().ok_or("camera is not initialized")?;
let size = camera.viewport.window_size;
if size.width == 0 || size.height == 0 {
return Err("cannot capture a zero-sized display frame".to_string());
}
self.pending_ui_screenshot = Some((path.into(), FrameCaptureBuffer::new(&gpu.device, size.width, size.height)));
Ok(())
}consumed one call site later than the scene capture, with the ordering spelled out directly in the source because it's easy to get backwards:
// This must stay after `gui.renderer.render`: `render_addon_frame` captures a scene
// before entropy_gui is composited, while browser BDD evidence must include controls,
// loading/error labels, and the rendered HTML itself.
if let Some((path, capture)) = self.pending_ui_screenshot.take() {
// ...capture_frame, submit, get_frame_data, image::save_buffer...
}Reading this frame back off the GPU needs wgpu::TextureUsages::COPY_SRC on the swapchain and depth textures, which weren't set for that purpose before - a one-flag addition in both pipeline.rs and startup.rs, easy to miss and the kind of thing that fails silently as a black or corrupted capture rather than a compile error if it's forgotten.
The result contract
The live run writes one JSON file the test binary asserts against - status, a per-action outcome log, the model values the scripted controls should have produced, and the artifact paths:
fn write_result(&self, status: &str, message: Option<&str>) {
let result = serde_json::json!({
"status": status,
"message": message,
"actions": self.outcomes,
"current_url": "https://invalid.example.test",
"history": ["https://example.com", "https://www.iana.org/domains/example"],
"history_index": 1,
"bookmarks": ["https://www.iana.org/domains/example"],
"artifacts": self.artifacts,
});
// ...
}tests/browser_bdd.rs reads it back, asserts status == "passed", and checks every listed artifact path is actually a file on disk - so a driver bug that requests a capture but never writes the PNG fails the test, rather than silently reporting success with a missing screenshot.
Evidence
Same machine as recent posts in this series: Intel UHD Graphics 770 (integrated), 12th Gen Intel Core i5-12500, 32GB RAM, Windows 11 Pro 10.0.26200. rustc 1.94.1, cargo 1.94.1, deno 2.6.7.
cd examples/studio-bundle && npm run build-html-ui-demo
cargo test --test browser_bdd
Real output from this session, not reconstructed:
Feature: Scriptless browser loop
Scenario: Follow a link and return through history
✔ Given the browser has loaded "https://example.com"
✔ When the browser follows "https://www.iana.org/domains/example"
✔ Then the current URL is "https://www.iana.org/domains/example"
✔ When the browser goes back
✔ Then the current URL is "https://example.com"
✔ When the browser goes forward
✔ Then the current URL is "https://www.iana.org/domains/example"
Scenario: Bookmark the current page
✔ Given the browser has loaded "https://example.com"
✔ When the browser bookmarks the current page
✔ Then "https://example.com" is a bookmark
Scenario: A failed navigation becomes visible state
✔ Given the browser has loaded "https://example.com"
✔ When the browser fetch fails for "https://invalid.example.test"
✔ Then the browser shows a fetch error
[Summary]
1 feature
3 scenarios (3 passed)
13 steps (13 passed)
[Live browser BDD]
✔ launched real HTML UI demo in test mode
✔ 13 stable-ID actions completed
✔ 4 composed PNG checkpoints written
✔ result JSON: ...\test-artifacts/browser-bdd\result.json
[Summary] 1 live-ui feature (passed)
The four PNGs that run actually wrote, straight from test-artifacts/browser-bdd/, unedited:




Worth pointing out directly: the second and third screenshots still show the previously documented fixed-character-width text clipping - "Example Domair," truncated RFC numbers - unfixed as of this session. That bug being visible here, un-worked-around, is itself a small piece of evidence that these are real renders off the real layout path and not staged images.
First-party diff across the whole arc this post and the next one cover (git diff --stat from just before this work started through the last commit of the arc):
.gitignore | 3 +-
Cargo.lock | 398 +++++++++++++++++++--
Cargo.toml | 13 +
.../studio-bundle/src/apps/html_ui_demo_addon.ts | 83 ++++-
src/core/pipeline.rs | 37 +-
src/deno/addon_engine.rs | 16 +-
src/deno/addon_ops.rs | 95 ++++-
src/deno/addon_setup.js | 16 +-
src/deno/net.rs | 14 +-
src/entropy_gui/widgets/hyperlink.rs | 14 +-
src/startup.rs | 189 +++++++++-
tests/browser_bdd.rs | 123 +++++++
tests/features/browser_live.feature | 54 +++
tests/features/browser_loop.feature | 23 ++
14 files changed, 1019 insertions(+), 59 deletions(-)
Primary sources checked directly rather than assumed: cucumber 0.23.0's own docs.rs page documents World::run(path) as parsing and executing every .feature file under that path, and the #[given]/#[when]/#[then] attribute macros as registering step functions against a regex or Cucumber Expression - both confirmed against the actual crate docs, not remembered from training data. wgpu 27.0.1's TextureUsages documents COPY_SRC as required for a texture to be the source of a copy_texture_to_buffer/copy_texture_to_texture call, which is exactly the read-back request_ui_screenshot performs. gherkin 0.16.0's source (Feature::parse/Step.value, read directly rather than assumed) confirms value is the step text with its keyword already stripped and trimmed, which is what browser_bdd_action_from_step's exact-text matching depends on.
Decision log
Two tiers, not one. A pure-Rust model behind Gherkin gives near-instant feedback on the rules (history/bookmark/error-state logic) every time cargo test runs. Only the slower live tier proves the real TypeScript addon, the real op layer, and the real GPU compositing pipeline actually implement those rules - and it's expensive enough (a real window, real network fetches, ~120-frame settle waits) that it deserves to stay a separate, explicitly-named tier rather than being inlined into the fast suite.
Inject widget events, not OS input. This environment has already demonstrated, in the stylus and kanban posts, that synthetic OS-level input here is unreliable (keyboard injection generates no WindowEvent at all; mouse-coordinate clicks miss after a reflow). Pushing directly onto AddonContext.ui_events sidesteps both problems by using the one channel that's already guaranteed correct - it's the same channel real input already funnels through - instead of trying to make OS-level synthetic input more reliable.
An environment variable, not a build feature, gates the driver. BrowserBddDriver::from_environment() returns None unless ENTROPY_BROWSER_BDD_RESULT is set. A compile-time feature flag would need a separate test-only build of the example binary; an env var means the exact same binary a human runs is the one the test suite drives, with zero chance the driver code path ships active by accident in a normal run.
Capture after entropy_gui's render pass, in a separate call from the existing scene capture. The video exporter's capture path exists for a different purpose (a clean scene render for a video frame) and deliberately does not include GUI chrome. Reusing it would have meant either polluting video export with UI, or writing screenshots that don't actually show the thing under test.
Parse the feature file with a small hand-rolled step mapper, not cucumber's own step-execution engine. The fast tier already proves cucumber itself can drive a World correctly; the live tier's World would have to be the running windowed app, and winit wants to own its event loop on a single thread in a way that doesn't compose cleanly with an async cucumber::World's step functions without a real channel-based bridge between the two. gherkin::Feature::parse plus a plain match-shaped translator gets the load-bearing property this feature actually needed - the .feature file, not a hand-written list, decides what the live run does - without redesigning how the live tier's process is driven. Worth being precise about the distinction: cucumber doesn't execute browser_live.feature's scenarios here, a small parser does; only browser_loop.feature's fast tier gets cucumber's real step-execution engine.
Failure notes
A three-frame wait was too short once the network fetch was actually real. The driver's first version waited a fixed 3 frames between clicking "Go" and capturing the "loading-example" screenshot - fine against a fast local render loop's own frame budget, but Entropy.Net.fetchText/pollText resolves once per rendered frame, and a real DNS lookup plus TLS handshake to example.com routinely takes longer than 3 frames at this machine's render rate. The captured evidence was flaky - sometimes the settled page, sometimes still "Fetching...". Fixed by widening the two post-navigation waits to 120 frames, with the reasoning left directly in the source rather than a bare magic number:
// Network polling happens once per rendered frame. Leave a real settling
// window before evidence capture rather than photographing the transient
// "Fetching..." state on a fast local render loop.
BrowserBddAction::Wait(120),browser_live.feature documented the live tier; it didn't drive it - until reconciling the two surfaced real drift between them. The first version of this suite had exactly the gap the rest of this post has already fixed: tests/browser_bdd.rs only ever ran BrowserWorld::run on browser_loop.feature, and browser_live.feature's @live-ui scenarios were hand-written Gherkin describing what BrowserBddDriver's separately hand-written VecDeque happened to do. Actually wiring the feature file up as the driver's real input (rather than just asserting the intent to do so) meant first making the two agree, and they didn't: the feature file said And I advance 3 frames after clicking "Go," while the driver's own code - fixed earlier this session, after the timing failure noted above - actually waited 120 frames; and the feature file never mentioned clicking into "webpage" mode at all, an action the driver always performed first. Both were silent drift, caught only because closing the gap required reading both sources side by side instead of trusting either one. The corrected feature file's frame counts and steps are the ones quoted earlier in this post, and cargo test --test browser_bdd re-run after the fix produced the byte-for-byte same result.json and the same four PNGs as before - the parser change is load-bearing for how the sequence is produced, not a behavior change in what it does.
What's next
- Extend this same event-injection pattern to the still-open
text-input-focus-bugbacklog item from the kanban post - a live-tier BDD scenario is a plausible way to actually pin that report down without relying on synthetic OS keyboard input, which this session has already shown doesn't work in this environment. - A real
cucumber::Worlddriving the live tier directly (see the decision log above) remains the more architecturally correct version of this, if a future session decides the channel-based bridge to winit's event loop is worth building. - The browser-feature work this suite exists to validate is next.