diff --git a/README.md b/README.md index 79c28de..443771d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ resources: # headless: false (default true; or drive it from an env var: headless: ${BROWSER_HEADLESS:-true}) # window: 1280x800 (default) # find_timeout_secs: 5 (default; 0 disables the wait) - # on_failure: screenshot,console (default: screenshot,console,network; "none" writes nothing) + # on_failure: screenshot,console (default: screenshot,console,network; add dom for the page source; "none" writes nothing) # capabilities: {} (raw WebDriver capabilities merged on top of what the plugin builds) firefox: # managed mode: no url browser: firefox @@ -116,7 +116,7 @@ Selectors are CSS by default; prefix with `xpath=` for an XPath expression, or ` | 11 | action | `I execute the script:` | runs the doc string as JavaScript in the page; the return value becomes `<>` | | 12 | action | `I read the "" element text as ""` | stores an element's rendered text in a variable | | 13 | action | `I read the "" attribute of "" as ""` | stores an element's attribute in a variable; a missing attribute fails | -| 14 | action | `I take a screenshot` | writes a PNG of the page into the artifacts directory | +| 14 | action | `I take a screenshot` | writes a PNG of the page as `screenshot-.png` into the artifacts directory | | 15 | assertion | `I should be on ""` | the current URL's path and query (or the whole absolute URL) equal this | | 16 | assertion | `the page title should be ""` | the document title equals this exactly | | 17 | assertion | `the page should contain ""` | the rendered text of the page contains this | @@ -134,14 +134,16 @@ Selectors are CSS by default; prefix with `xpath=` for an XPath expression, or ` | 29 | assertion | `the browser console should have no errors` | no console.error and no uncaught exception since the scenario started | | 30 | assertion | `the browser should have sent a "" request to ""` | some request of the scenario has this method and a path starting with this | | 31 | assertion | `the last request to "" should have status ""` | the most recent request whose path starts with this has completed with this status | +| 32 | action | `I dump the DOM` | writes the page's serialized HTML as `dom-.html` into the artifacts directory | +| 33 | action | `I dump the DOM of ""` | writes one element's `outerHTML` as `dom-.html`; the selector is CSS, `xpath=` or `text=`, and the element must already be there | ## Waits -An action (0–14) waits up to `find_timeout_secs` for its element to appear, polling every 100 ms; `0` disables the wait and the action fails immediately on a missing element. An assertion (15–25, 29–31) looks exactly once — it never polls on its own — so a condition that has not settled yet (a click that triggers an async render, a request still in flight) is armed with the host's own eventual assertion: `I expect the next assertion to pass within "N" seconds`. The dump/read-status steps (26–28) read the console and network buffers as they stand at that instant and never wait either — arm an eventual assertion (30 or 31) first when the request they need might still be in flight. +An action (0–14) waits up to `find_timeout_secs` for its element to appear, polling every 100 ms; `0` disables the wait and the action fails immediately on a missing element. An assertion (15–25, 29–31) looks exactly once — it never polls on its own — so a condition that has not settled yet (a click that triggers an async render, a request still in flight) is armed with the host's own eventual assertion: `I expect the next assertion to pass within "N" seconds`. The dump/read-status steps (26–28) read the console and network buffers as they stand at that instant and never wait either — arm an eventual assertion (30 or 31) first when the request they need might still be in flight. The DOM dumps (32–33) take the page as it is at that instant too, and `I dump the DOM of` fails at once, without waiting, on a missing element — arm an eventual assertion about the element first when it may not have rendered yet. ## Evidence -A failed step dumps the current page's URL and title, always; a screenshot (PNG, path printed), the console log and the network log, unless `on_failure` says otherwise. `on_failure` is a comma-separated subset of `screenshot`, `console`, `network`, or `none` to write nothing beyond the URL and title. +A failed step dumps the current page's URL and title, always; a screenshot (`screenshot-.png`, path printed), the console log and the network log, unless `on_failure` says otherwise. `on_failure` is a comma-separated subset of `screenshot`, `console`, `network`, `dom`, or `none` to write nothing beyond the URL and title. `dom` is off by default: it writes the page source as `dom-.html` and prints its path, and that file is raw (see Known limits). ## Console and network @@ -164,6 +166,8 @@ One browser session per feature file, opened on its first browser step and close 3. **No iframes, tabs or alerts.** Every lookup runs against the top-level document of the current tab; there is no step to switch frames, open or close a tab, or handle a native `alert`/`confirm`/`prompt`. 4. **Managed mode is Unix-only.** `Mode::Managed` on a non-Unix build fails naming `url` as the way forward; remote mode is unaffected everywhere. +5. **DOM dumps are the serialized document, written raw.** `I dump the DOM` and `on_failure: dom` use the WebDriver page-source endpoint, which leaves out shadow roots and iframe contents; `I dump the DOM of` is the element's `outerHTML`, which has the same blind spot. Nothing is masked: hidden fields, CSRF tokens and attribute values end up in the file as they are, so keep the artifacts directory out of anything shared. `` counts the dumps of that kind in the scenario, from 1, and starts over at the next scenario. + ## Example A full demo — a site, five feature files, a compose file — lives under `examples/`; see `examples/README.md` for how to run it. diff --git a/examples/README.md b/examples/README.md index 914864e..e15ede4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ The site is plain HTML and vanilla JavaScript in `examples/site/static/`; edit a | `features/login.feature` | `I am on`, `I fill in` by label / name, `I press`, `I should be on` with a query string, the per-scenario reset (a login never leaks into the next scenario) | | `features/order.feature` | The bridge: a click in the browser, an eventual assertion that the browser itself sent the `POST /api/orders` request and it answered `201`, the status read into a variable, a clean console, the order id read into a variable, then the host's own HTTP steps read the same order from the API | | `features/forms.feature` | select, checkbox, textarea, attribute and script results as variables, a form round trip | -| `features/evidence.feature` | `I take a screenshot` and where it lands under `I am in debug mode` | +| `features/evidence.feature` | `I take a screenshot`, `I dump the DOM` and `I dump the DOM of` and where they land under `I am in debug mode`; a second dump of a kind gets the next `-` | | `features/console.feature` | `the browser console should have no errors` on a clean page, then `I dump the browser console` / `I dump the network log` for their own sake | ## Watching the browser diff --git a/examples/browser.yaml b/examples/browser.yaml index 8ebd90a..ec70932 100644 --- a/examples/browser.yaml +++ b/examples/browser.yaml @@ -12,6 +12,7 @@ resources: url: http://localhost:4444 # docker compose up -d base_url: http://localhost:3000 # cargo run --example site headless: ${BROWSER_HEADLESS:-true} # BROWSER_HEADLESS=false to watch + # on_failure: screenshot,console,network,dom # add dom to get the page source on a failed step firefox: # managed mode: no url, the plugin brings the browser browser: firefox base_url: http://localhost:3000 diff --git a/examples/features/evidence.feature b/examples/features/evidence.feature index 176cdfe..3e94914 100644 --- a/examples/features/evidence.feature +++ b/examples/features/evidence.feature @@ -5,3 +5,19 @@ Feature: evidence on demand And I am in debug mode When I take a screenshot Then the "text=Sign in" element should be visible + + Scenario: the page source and one element, for writing a selector + Given I am on "/login" + And I am in debug mode + When I dump the DOM + And I dump the DOM of "h1" + Then the "text=Sign in" element should be visible + + Scenario: a second dump of a kind gets the next number + Given I am on "/login" + And I am in debug mode + When I take a screenshot + And I take a screenshot + And I dump the DOM + And I dump the DOM + Then the "text=Sign in" element should be visible diff --git a/src/config.rs b/src/config.rs index 494c63d..79f423a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -102,6 +102,7 @@ pub struct OnFailure { pub screenshot: bool, pub console: bool, pub network: bool, + pub dom: bool, } impl OnFailure { @@ -110,16 +111,18 @@ impl OnFailure { screenshot: false, console: false, network: false, + dom: false, }; for token in s.split(',').map(str::trim).filter(|t| !t.is_empty()) { match token { "screenshot" => on.screenshot = true, "console" => on.console = true, "network" => on.network = true, + "dom" => on.dom = true, "none" => {} other => { return Err(format!( - "\"on_failure\" token {other:?} is not one of screenshot, console, network, none" + "\"on_failure\" token {other:?} is not one of screenshot, console, network, dom, none" )); } } @@ -243,7 +246,7 @@ const FIELDS: &[Field] = &[ name: "on_failure", required: false, value_type: None, - description: "comma-separated subset of screenshot, console, network to write on a failed step, or none; defaults to all three", + description: "comma-separated subset of screenshot, console, network, dom to write on a failed step, or none; defaults to screenshot,console,network (dom is off: the page source is written as is, hidden fields and tokens included)", example: Some("screenshot,console"), }, Field { @@ -437,6 +440,7 @@ impl InstanceConfig { screenshot: true, console: true, network: true, + dom: false, }, Some(s) => OnFailure::parse(&s)?, }; @@ -497,6 +501,7 @@ mod tests { assert_eq!(c.window, (1280, 800)); assert_eq!(c.find_timeout, Duration::from_secs(5)); assert!(c.on_failure.screenshot && c.on_failure.console && c.on_failure.network); + assert!(!c.on_failure.dom, "the DOM is written raw, so it is opt-in"); assert!(c.base_url.is_none()); } @@ -555,6 +560,9 @@ mod tests { body["on_failure"] = json!("none"); let c = InstanceConfig::parse(&body).expect("valid"); assert!(!c.on_failure.screenshot && !c.on_failure.console && !c.on_failure.network); + body["on_failure"] = json!("dom"); + let c = InstanceConfig::parse(&body).expect("dom"); + assert!(c.on_failure.dom && !c.on_failure.screenshot); body["on_failure"] = json!("screenshot"); let c = InstanceConfig::parse(&body).expect("valid"); assert!(c.on_failure.screenshot && !c.on_failure.console); diff --git a/src/instance.rs b/src/instance.rs index d41a80a..a4e8141 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; #[cfg(unix)] use std::sync::{Mutex, PoisonError}; use std::time::Duration; @@ -29,6 +30,10 @@ pub struct Instance { pub session: Session, pub info: SessionInfo, pub bidi: Option, + /// How many screenshots and DOM dumps this scenario has written, so a + /// file name says which one it is. Cleared by `reset`. + pub screenshots: AtomicU32, + pub doms: AtomicU32, /// The driver process this instance started, managed mode only. #[cfg(unix)] driver: Mutex>, @@ -79,6 +84,8 @@ impl Instance { session, info, bidi: None, + screenshots: AtomicU32::new(0), + doms: AtomicU32::new(0), #[cfg(unix)] driver: Mutex::new(_driver), }; @@ -126,6 +133,8 @@ impl Instance { self.session .navigate("about:blank") .map_err(|e| format!("leaving the page: {e}"))?; + self.screenshots.store(0, Ordering::Relaxed); + self.doms.store(0, Ordering::Relaxed); if let Some(bidi) = &self.bidi && let Ok(mut b) = bidi.buffers().lock() { diff --git a/src/reply.rs b/src/reply.rs index af9c273..6a87c55 100644 --- a/src/reply.rs +++ b/src/reply.rs @@ -59,6 +59,16 @@ impl Diagnostic { } } + /// A file the plugin wrote: the host prints the path, the reader opens it. + pub fn file(title: impl Into, kind: &'static str, path: impl Into) -> Self { + Self { + title: title.into(), + kind, + content: None, + path: Some(path.into()), + } + } + fn to_json(&self) -> Value { json!({ "title": self.title, diff --git a/src/steps.rs b/src/steps.rs index 750c3e4..92f3ac5 100644 --- a/src/steps.rs +++ b/src/steps.rs @@ -2,6 +2,7 @@ //! cannot drift. The index of a step in `STEPS` is its identity. use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; use serde_json::{Map, Value}; use url::Url; @@ -93,7 +94,7 @@ pub const STEPS: &[Step] = &[ Step { pattern: r#"^I take a screenshot$"#, kind: "action", - description: "writes a PNG of the page into the artifacts directory", + description: "writes a PNG of the page as screenshot-.png into the artifacts directory", }, Step { pattern: r#"^I should be on "(?P[^"]+)"$"#, @@ -180,6 +181,16 @@ pub const STEPS: &[Step] = &[ kind: "assertion", description: "the most recent request whose path starts with this has completed with this status", }, + Step { + pattern: r#"^I dump the DOM$"#, + kind: "action", + description: "writes the page's serialized HTML as dom-.html into the artifacts directory; shadow roots and iframe contents are not in it", + }, + Step { + pattern: r#"^I dump the DOM of "(?P[^"]+)"$"#, + kind: "action", + description: "writes one element's outerHTML as dom-.html; the element must be there already, the step never waits", + }, ]; pub fn steps_json() -> String { @@ -345,6 +356,14 @@ fn write_artifact(ctx: &Ctx, name: &str, bytes: &[u8]) -> Result Ok(path) } +/// `-.`, `n` counting up from 1 within the scenario. +fn numbered(counter: &AtomicU32, stem: &str, ext: &str) -> String { + format!( + "{stem}-{}.{ext}", + counter.fetch_add(1, Ordering::Relaxed) + 1 + ) +} + /// An action's element: wait up to `find_timeout`, then fail naming it. fn act<'a>(instance: &'a Instance, lookup: &Lookup) -> Result, Fail> { let timeout = instance.config.find_timeout; @@ -442,7 +461,8 @@ fn run(instance: &Instance, index: u32, req: &Request) -> Result { let png = s.screenshot()?; - let path = write_artifact(&req.ctx, "screenshot.png", &png)?; + let name = numbered(&instance.screenshots, "screenshot", "png"); + let path = write_artifact(&req.ctx, &name, &png)?; if req.ctx.debug { eprintln!("[browser] screenshot: {}", path.display()); } @@ -621,6 +641,21 @@ fn run(instance: &Instance, index: u32, req: &Request) -> Result {} } } + 32 | 33 => { + let html = if index == 32 { + s.source()? + } else { + let lookup = find::selector(&arg(0)); + look(instance, &lookup)? + .ok_or_else(|| fatal(format!("no {} on the page", lookup.what)))? + .outer_html()? + }; + let name = numbered(&instance.doms, "dom", "html"); + let path = write_artifact(&req.ctx, &name, html.as_bytes())?; + if req.ctx.debug { + eprintln!("[browser] dom: {}", path.display()); + } + } other => return Err(fatal(format!("unknown step index {other}"))), } Ok(vars) @@ -645,8 +680,10 @@ fn evidence(instance: &Instance, req: &Request) -> Vec { .session .screenshot() .map_err(|e| fatal(e.to_string())) - .and_then(|png| write_artifact(&req.ctx, "screenshot.png", &png)) - { + .and_then(|png| { + let name = numbered(&instance.screenshots, "screenshot", "png"); + write_artifact(&req.ctx, &name, &png) + }) { Ok(path) => out.push(Diagnostic::image("Screenshot", path.display().to_string())), Err(e) => out.push(Diagnostic::text( "Screenshot", @@ -654,6 +691,19 @@ fn evidence(instance: &Instance, req: &Request) -> Vec { )), } } + if instance.config.on_failure.dom { + match instance + .session + .source() + .map_err(|e| fatal(e.to_string())) + .and_then(|html| { + let name = numbered(&instance.doms, "dom", "html"); + write_artifact(&req.ctx, &name, html.as_bytes()) + }) { + Ok(path) => out.push(Diagnostic::file("DOM", "html", path.display().to_string())), + Err(e) => out.push(Diagnostic::text("DOM", format!("not taken: {}", e.error))), + } + } if let Some(bidi) = &instance.bidi && let Ok(b) = bidi.buffers().lock() { @@ -685,8 +735,8 @@ mod tests { let steps: Vec = serde_json::from_str(&steps_json()).expect("JSON"); assert_eq!( steps.len(), - 32, - "phase 1 declares 26 steps, task 11 adds 6 more; append, never reorder" + 34, + "phase 1 declares 26 steps, task 11 adds 6, issue 96 adds 2; append, never reorder" ); for (i, s) in steps.iter().enumerate() { let p = s["pattern"].as_str().expect("pattern"); diff --git a/src/webdriver.rs b/src/webdriver.rs index 8422428..f8b0f2a 100644 --- a/src/webdriver.rs +++ b/src/webdriver.rs @@ -339,6 +339,11 @@ impl Session { self.post("execute/sync", json!({"script": script, "args": args})) } + /// The serialized document. Shadow roots and iframe contents are not in it. + pub fn source(&self) -> Result { + Ok(self.get("source")?.as_str().unwrap_or("").to_string()) + } + pub fn screenshot(&self) -> Result, Error> { let encoded = self.get("screenshot")?; let encoded = encoded.as_str().unwrap_or(""); @@ -400,6 +405,14 @@ impl Element<'_> { self.session.get(&self.tail(&format!("property/{name}"))) } + pub fn outer_html(&self) -> Result { + let reference = json!({ELEMENT_KEY: self.id}); + let html = self + .session + .execute("return arguments[0].outerHTML;", vec![reference])?; + Ok(html.as_str().unwrap_or("").to_string()) + } + pub fn displayed(&self) -> Result { Ok(self .session diff --git a/tests/e2e.rs b/tests/e2e.rs index 9baf983..dac6124 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -49,16 +49,27 @@ fn bddkit_bin() -> Option { .find(|p| p.is_file()) } +/// A missing stand is a skip on a laptop and a failure in CI (`CI` is set by +/// GitHub Actions): a job that cannot reach its browser must not go green +/// having run no example at all. +fn skip(reason: &str) { + assert!(std::env::var_os("CI").is_none(), "no stand in CI: {reason}"); + eprintln!("SKIP: {reason}"); +} + macro_rules! require_stand { () => { match (bddkit_bin(), browser_is_up()) { (Some(bin), true) => bin, (None, _) => { - eprintln!("SKIP: no bddkit binary — set BDDKIT_BIN, put bddkit on PATH, or build ../bddkit"); + skip("no bddkit binary — set BDDKIT_BIN, put bddkit on PATH, or build ../bddkit"); return; } (_, false) => { - eprintln!("SKIP: no WebDriver at {} — `docker compose up -d` or set BDDKIT_BROWSER_URL", browser_url()); + skip(&format!( + "no WebDriver at {} — `docker compose up -d` or set BDDKIT_BROWSER_URL", + browser_url() + )); return; } } @@ -242,7 +253,7 @@ fn a_failing_step_dumps_the_page_a_screenshot_and_the_last_exchange() { ); let screenshot = stdout .lines() - .find(|l| l.trim_end().ends_with("screenshot.png")) + .find(|l| l.trim_end().ends_with("screenshot-1.png")) .expect("a screenshot path is printed"); assert!(Path::new(screenshot.trim()).is_file(), "{screenshot}"); } @@ -297,9 +308,7 @@ fn doctor_live_probes_the_browser() { #[test] fn the_examples_pass_on_a_managed_firefox() { let Some(bin) = bddkit_bin() else { - eprintln!( - "SKIP: no bddkit binary — set BDDKIT_BIN, put bddkit on PATH, or build ../bddkit" - ); + skip("no bddkit binary — set BDDKIT_BIN, put bddkit on PATH, or build ../bddkit"); return; }; if !managed_wanted() { diff --git a/tests/stub.rs b/tests/stub.rs index c8fd461..e0def05 100644 --- a/tests/stub.rs +++ b/tests/stub.rs @@ -37,6 +37,8 @@ pub struct StubState { /// `find` answers "no such element" this many times first. pub not_found_first: u32, pub script_result: Value, + /// What `GET /source` answers. + pub source: String, /// When true the session reply advertises `webSocketUrl` = the stub's `/bidi`. pub bidi: bool, /// When true the session reply advertises `webSocketUrl` as an @@ -160,6 +162,7 @@ async fn handle( ("DELETE", ["session", "s1", "cookie"]) => reply(Value::Null), ("POST", ["session", "s1", "execute", "sync"]) => reply(st.script_result.clone()), ("GET", ["session", "s1", "screenshot"]) => reply(json!(PNG_1X1)), + ("GET", ["session", "s1", "source"]) => reply(json!(st.source)), ("POST", ["session", "s1", "element"]) | ("POST", ["session", "s1", "element", _, "element"]) => { let using = req["using"].as_str().unwrap_or(""); @@ -975,7 +978,7 @@ fn i_take_a_screenshot_writes_a_png_into_artifacts_dir() { dispatch(handle, SCREENSHOT, &[], None, &target)["status"], "passed" ); - let png = std::fs::read(target.join("screenshot.png")) + let png = std::fs::read(target.join("screenshot-1.png")) .expect("the plugin creates artifacts_dir and writes the PNG"); assert_eq!(&png[..4], b"\x89PNG"); drop_instance(handle); @@ -1380,3 +1383,136 @@ fn a_failure_attaches_console_and_network_when_on_failure_asks() { assert_eq!(titles, vec!["Page", "Console", "Network", "WebDriver"]); drop_instance(handle); } + +const DUMP_DOM: u32 = 32; +const DUMP_DOM_OF: u32 = 33; + +#[test] +fn i_dump_the_dom_writes_the_page_source_into_artifacts_dir() { + let _guard = serial(); + let stub = start_stub(StubState { + source: "

Hi

".into(), + ..Default::default() + }); + let handle = init(&stub, json!({})); + let dir = artifacts(); + let target = dir.path().join("000001"); + assert_eq!( + dispatch(handle, DUMP_DOM, &[], None, &target)["status"], + "passed" + ); + let html = std::fs::read_to_string(target.join("dom-1.html")).expect("dom-1.html is written"); + assert_eq!(html, "

Hi

"); + drop_instance(handle); +} + +#[test] +fn i_dump_the_dom_of_an_element_writes_its_outer_html() { + let _guard = serial(); + let stub = start_stub(StubState { + elements: vec![element("#cart", "")], + script_result: json!("
2 items
"), + ..Default::default() + }); + let handle = init(&stub, json!({})); + let dir = artifacts(); + let target = dir.path().join("000001"); + assert_eq!( + dispatch(handle, DUMP_DOM_OF, &["#cart"], None, &target)["status"], + "passed" + ); + let html = std::fs::read_to_string(target.join("dom-1.html")).expect("dom-1.html is written"); + assert_eq!(html, "
2 items
"); + let calls = calls(&stub); + let script = calls + .iter() + .find(|c| c.starts_with("POST /session/s1/execute/sync") && c.contains("outerHTML")) + .expect("the outerHTML script ran"); + assert!( + script.contains(ELEMENT_KEY) && script.contains("\"e0\""), + "{script}" + ); + drop_instance(handle); +} + +#[test] +fn dumping_the_dom_of_a_missing_element_fails_at_once_and_never_waits() { + let _guard = serial(); + let stub = start_stub(StubState::default()); + let handle = init(&stub, json!({"find_timeout_secs": 5, "on_failure": "none"})); + let dir = artifacts(); + let target = dir.path().join("000001"); + let started = std::time::Instant::now(); + let r = dispatch(handle, DUMP_DOM_OF, &["#cart"], None, &target); + assert_eq!( + r["status"], "fatal", + "an action may not answer not_yet: {r}" + ); + assert!(r["error"].as_str().expect("error").contains("#cart"), "{r}"); + assert!(started.elapsed() < std::time::Duration::from_millis(500)); + assert!(!target.exists()); + drop_instance(handle); +} + +#[test] +fn on_failure_dom_adds_the_page_source_file_to_a_failure() { + let _guard = serial(); + let stub = start_stub(StubState { + source: "broken".into(), + ..Default::default() + }); + let handle = init(&stub, json!({"find_timeout_secs": 0, "on_failure": "dom"})); + let dir = artifacts(); + let target = dir.path().join("000001"); + let r = dispatch(handle, PRESS, &["Pay"], None, &target); + assert_eq!(r["status"], "fatal"); + let d = r["diagnostics"].as_array().expect("diagnostics"); + let titles: Vec<&str> = d.iter().map(|x| x["title"].as_str().expect("t")).collect(); + assert_eq!(titles, vec!["Page", "DOM", "WebDriver"], "{r}"); + let path = d[1]["path"] + .as_str() + .expect("the DOM diagnostic carries a path"); + assert_eq!( + std::fs::read_to_string(path).expect("the file exists"), + "broken" + ); + assert!( + d[2]["content"] + .as_str() + .expect("http") + .contains("POST session/s1/element"), + "the failed find stays the last exchange: {r}" + ); + drop_instance(handle); +} + +#[test] +fn artifact_names_count_up_within_a_scenario_and_start_over_after_a_reset() { + let _guard = serial(); + let stub = start_stub(StubState::default()); + let handle = init(&stub, json!({})); + let dir = artifacts(); + let names = |n: u32, step: u32| { + let target = dir.path().join(format!("{n:06}")); + assert_eq!( + dispatch(handle, step, &[], None, &target)["status"], + "passed" + ); + let mut files: Vec = std::fs::read_dir(&target) + .expect("written") + .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) + .collect(); + files.sort(); + files + }; + assert_eq!(names(1, SCREENSHOT), ["screenshot-1.png"]); + assert_eq!(names(2, SCREENSHOT), ["screenshot-2.png"]); + assert_eq!( + names(3, DUMP_DOM), + ["dom-1.html"], + "each kind counts on its own" + ); + assert_eq!(reset(handle)["ok"], true); + assert_eq!(names(4, SCREENSHOT), ["screenshot-1.png"]); + drop_instance(handle); +}