flowproof 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {flowproof-0.2.2 → flowproof-0.2.3}/Cargo.lock +8 -7
  2. {flowproof-0.2.2 → flowproof-0.2.3}/Cargo.toml +1 -1
  3. {flowproof-0.2.2 → flowproof-0.2.3}/PKG-INFO +1 -1
  4. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/src/web.rs +437 -11
  5. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/lib.rs +3 -1
  6. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/recorder.rs +582 -1
  7. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/rules.rs +108 -1
  8. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/spec.rs +89 -5
  9. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/src/lib.rs +53 -13
  10. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/llm_author_e2e.rs +2 -0
  11. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/notepad_author_e2e.rs +2 -0
  12. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/web_e2e.rs +300 -0
  13. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/app.rs +223 -0
  14. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/lib.rs +4 -2
  15. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/mock.rs +76 -0
  16. flowproof-0.2.3/crates/flowproof-driver/src/visual.rs +206 -0
  17. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-python/src/lib.rs +6 -3
  18. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-replay/Cargo.toml +1 -0
  19. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-replay/src/lib.rs +395 -3
  20. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-replay/tests/replay_calc.rs +285 -0
  21. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/schema/trace-v1.schema.json +66 -3
  22. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/src/format.rs +107 -0
  23. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +1 -1
  24. {flowproof-0.2.2 → flowproof-0.2.3}/flowproof/__init__.py +1 -1
  25. {flowproof-0.2.2 → flowproof-0.2.3}/pyproject.toml +1 -1
  26. {flowproof-0.2.2 → flowproof-0.2.3}/README.md +0 -0
  27. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/Cargo.toml +0 -0
  28. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/src/lib.rs +0 -0
  29. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/src/sap_com.rs +0 -0
  30. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/src/vision.rs +0 -0
  31. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/Cargo.toml +0 -0
  32. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/author.rs +0 -0
  33. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/clarify.rs +0 -0
  34. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/heal.rs +0 -0
  35. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/llm.rs +0 -0
  36. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/Cargo.toml +0 -0
  37. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/src/main.rs +0 -0
  38. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  39. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  40. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
  41. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  42. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  43. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  44. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  45. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  46. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  47. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  48. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  49. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  50. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/Cargo.toml +0 -0
  51. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/backend.rs +0 -0
  52. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/gdi.rs +0 -0
  53. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/oob.rs +0 -0
  54. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/recording.rs +0 -0
  55. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/redact.rs +0 -0
  56. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/window.rs +0 -0
  57. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-python/Cargo.toml +0 -0
  58. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-replay/src/report.rs +0 -0
  59. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/Cargo.toml +0 -0
  60. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/src/lib.rs +0 -0
  61. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/src/secret.rs +0 -0
  62. {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  63. {flowproof-0.2.2 → flowproof-0.2.3}/flowproof/cli.py +0 -0
  64. {flowproof-0.2.2 → flowproof-0.2.3}/flowproof/flow.py +0 -0
  65. {flowproof-0.2.2 → flowproof-0.2.3}/flowproof/mcp_server.py +0 -0
  66. {flowproof-0.2.2 → flowproof-0.2.3}/flowproof/py.typed +0 -0
@@ -722,7 +722,7 @@ dependencies = [
722
722
 
723
723
  [[package]]
724
724
  name = "flowproof-adapters"
725
- version = "0.2.2"
725
+ version = "0.2.3"
726
726
  dependencies = [
727
727
  "anyhow",
728
728
  "flowproof-driver",
@@ -738,7 +738,7 @@ dependencies = [
738
738
 
739
739
  [[package]]
740
740
  name = "flowproof-agent"
741
- version = "0.2.2"
741
+ version = "0.2.3"
742
742
  dependencies = [
743
743
  "chrono",
744
744
  "flowproof-driver",
@@ -753,7 +753,7 @@ dependencies = [
753
753
 
754
754
  [[package]]
755
755
  name = "flowproof-cli"
756
- version = "0.2.2"
756
+ version = "0.2.3"
757
757
  dependencies = [
758
758
  "ab_glyph",
759
759
  "clap",
@@ -771,7 +771,7 @@ dependencies = [
771
771
 
772
772
  [[package]]
773
773
  name = "flowproof-driver"
774
- version = "0.2.2"
774
+ version = "0.2.3"
775
775
  dependencies = [
776
776
  "image",
777
777
  "postgres",
@@ -785,7 +785,7 @@ dependencies = [
785
785
 
786
786
  [[package]]
787
787
  name = "flowproof-python"
788
- version = "0.2.2"
788
+ version = "0.2.3"
789
789
  dependencies = [
790
790
  "flowproof-agent",
791
791
  "flowproof-cli",
@@ -797,12 +797,13 @@ dependencies = [
797
797
 
798
798
  [[package]]
799
799
  name = "flowproof-replay"
800
- version = "0.2.2"
800
+ version = "0.2.3"
801
801
  dependencies = [
802
802
  "chrono",
803
803
  "flowproof-agent",
804
804
  "flowproof-driver",
805
805
  "flowproof-trace",
806
+ "image",
806
807
  "serde",
807
808
  "serde_json",
808
809
  "thiserror 2.0.18",
@@ -810,7 +811,7 @@ dependencies = [
810
811
 
811
812
  [[package]]
812
813
  name = "flowproof-trace"
813
- version = "0.2.2"
814
+ version = "0.2.3"
814
815
  dependencies = [
815
816
  "jsonschema",
816
817
  "serde",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/flowproof-driver", "crates/flowproof-trace", "crates/flowproof-replay", "crates/flowproof-agent", "crates/flowproof-adapters", "crates/flowproof-cli", "crates/flowproof-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.2"
6
+ version = "0.2.3"
7
7
  edition = "2021"
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/automators-com/flowproof"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowproof
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -12,7 +12,7 @@ use std::time::Duration;
12
12
  use flowproof_driver::{AppDriver, DriverError, KeyMod, PixelRect, UiaSelector, WebSession};
13
13
  use headless_chrome::browser::tab::{ModifierKey, Tab};
14
14
  use headless_chrome::protocol::cdp::Target::CreateTarget;
15
- use headless_chrome::protocol::cdp::{Network, Page};
15
+ use headless_chrome::protocol::cdp::{Emulation, Input, Network, Page};
16
16
  use headless_chrome::{Browser, LaunchOptions};
17
17
 
18
18
  use crate::AdapterError;
@@ -23,10 +23,13 @@ fn web_err(context: &str, err: impl std::fmt::Display) -> DriverError {
23
23
  DriverError::Uia(format!("web: {context}: {err}"))
24
24
  }
25
25
 
26
- /// Launch a fresh headless Chromium (`CHROME` env var overrides the binary).
27
- fn launch_browser() -> Result<Browser, AdapterError> {
26
+ /// Launch a fresh headless Chromium (`CHROME` env var overrides the
27
+ /// binary), optionally with extra command-line flags.
28
+ fn launch_browser(extra_args: &[String]) -> Result<Browser, AdapterError> {
29
+ let os_args: Vec<std::ffi::OsString> = extra_args.iter().map(Into::into).collect();
28
30
  let mut options = LaunchOptions::default_builder();
29
31
  options.headless(true).sandbox(false);
32
+ options.args(os_args.iter().map(AsRef::as_ref).collect());
30
33
  if let Ok(path) = std::env::var("CHROME") {
31
34
  options.path(Some(path.into()));
32
35
  }
@@ -59,7 +62,7 @@ fn shared_browser() -> Result<Browser, AdapterError> {
59
62
  return Ok(browser.clone());
60
63
  }
61
64
  }
62
- let browser = launch_browser()?;
65
+ let browser = launch_browser(&[])?;
63
66
  let keepalive = browser
64
67
  .new_tab()
65
68
  .map_err(|e| AdapterError::Web(format!("opening keep-alive tab: {e}")))?;
@@ -78,6 +81,49 @@ pub struct WebAppDriver {
78
81
  /// Session staged via [`AppDriver::stage_session`], applied by the next
79
82
  /// `launch` before the page loads.
80
83
  staged_session: Option<WebSession>,
84
+ /// Recent console/log lines from the page (bounded ring buffer),
85
+ /// filled by a CDP event listener registered at launch — read
86
+ /// retroactively when a step fails ([`AppDriver::debug_bundle`]).
87
+ console: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
88
+ /// Network mocks staged via [`AppDriver::stage_mocks`], installed by
89
+ /// the next `launch` before navigation (CDP Fetch interception).
90
+ staged_mocks: Vec<flowproof_driver::WebMock>,
91
+ /// Browser config staged via [`AppDriver::stage_browser`], applied by
92
+ /// the next `launch`: viewport/UA per-tab; extra flags swap in a
93
+ /// private browser (flags only apply at process start).
94
+ staged_browser: Option<flowproof_driver::WebBrowserConfig>,
95
+ }
96
+
97
+ /// Cap on retained console lines — enough context for a failure, bounded
98
+ /// so a chatty app can't balloon the run bundle.
99
+ const CONSOLE_TAIL_CAP: usize = 100;
100
+
101
+ /// Standard base64 for CDP `Fetch.fulfillRequest` bodies — hand-rolled
102
+ /// (~15 lines) rather than pulling a crate into the adapter for one call.
103
+ fn base64_encode(data: &[u8]) -> String {
104
+ const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
105
+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
106
+ for chunk in data.chunks(3) {
107
+ let b = [
108
+ chunk[0],
109
+ *chunk.get(1).unwrap_or(&0),
110
+ *chunk.get(2).unwrap_or(&0),
111
+ ];
112
+ let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
113
+ out.push(ALPHABET[(n >> 18) as usize & 63] as char);
114
+ out.push(ALPHABET[(n >> 12) as usize & 63] as char);
115
+ out.push(if chunk.len() > 1 {
116
+ ALPHABET[(n >> 6) as usize & 63] as char
117
+ } else {
118
+ '='
119
+ });
120
+ out.push(if chunk.len() > 2 {
121
+ ALPHABET[n as usize & 63] as char
122
+ } else {
123
+ '='
124
+ });
125
+ }
126
+ out
81
127
  }
82
128
 
83
129
  impl WebAppDriver {
@@ -86,10 +132,13 @@ impl WebAppDriver {
86
132
  pub fn new() -> Result<Self, AdapterError> {
87
133
  if std::env::var_os("FLOWPROOF_NO_SHARED_BROWSER").is_some() {
88
134
  return Ok(Self {
89
- browser: launch_browser()?,
135
+ browser: launch_browser(&[])?,
90
136
  context_id: None,
91
137
  tab: None,
92
138
  staged_session: None,
139
+ console: Default::default(),
140
+ staged_mocks: Vec::new(),
141
+ staged_browser: None,
93
142
  });
94
143
  }
95
144
  let browser = shared_browser()?;
@@ -102,6 +151,9 @@ impl WebAppDriver {
102
151
  context_id: Some(context_id),
103
152
  tab: None,
104
153
  staged_session: None,
154
+ console: Default::default(),
155
+ staged_mocks: Vec::new(),
156
+ staged_browser: None,
105
157
  })
106
158
  }
107
159
 
@@ -297,11 +349,25 @@ impl std::fmt::Display for WebLocator {
297
349
  /// tried in order: (1) exact match on the element's DIRECT text nodes (its
298
350
  /// own text, so a sibling avatar's initials can never fuse with a label
299
351
  /// into "ETE2E Test Runner's Team"); (2) exact match on the concatenated
300
- /// subtree text (covers labels wrapped in spans); (3) and (4) the same two
301
- /// as prefix matches (a leading match is accepted when the accessible name
302
- /// carries trailing detail catalog cards, chips like `ID: …`).
303
- fn text_xpaths(text: &str) -> [String; 4] {
352
+ /// subtree text (covers labels wrapped in spans); (3) `<label>` association
353
+ /// by the label's exact text both the wrapping form
354
+ /// `<label>Name: <input/></label>` and `<label for>`/`id` pairing; (4) and
355
+ /// (5) the own-text/subtree rungs as prefix matches (a leading match is
356
+ /// accepted when the accessible name carries trailing detail — catalog
357
+ /// cards, chips like `ID: …`); (6) label association as a prefix match
358
+ /// (so `Name` finds the field labelled `Name:`); (7) and (8) ASCII
359
+ /// case-insensitive fallbacks of the exact and prefix rungs — role names
360
+ /// are case-insensitive in Playwright, and real pages disagree with specs
361
+ /// about capitalization ("Close Account" vs "Close account"). A
362
+ /// case-sensitive match always wins over a case-insensitive one.
363
+ fn text_xpaths(text: &str) -> Vec<String> {
364
+ const UPPER: &str = "'ABCDEFGHIJKLMNOPQRSTUVWXYZ'";
365
+ const LOWER: &str = "'abcdefghijklmnopqrstuvwxyz'";
304
366
  let lit = xpath_literal(text);
367
+ let lower_lit = xpath_literal(&text.to_ascii_lowercase());
368
+ let ci = |expr: &str| format!("translate({expr}, {UPPER}, {LOWER})={lower_lit}");
369
+ let ci_prefix =
370
+ |expr: &str| format!("starts-with(translate({expr}, {UPPER}, {LOWER}), {lower_lit})");
305
371
  let build = |by_text: String, by_label: String, by_placeholder: String| {
306
372
  format!(
307
373
  "//*[self::button or self::a or self::summary or @role='button' or \
@@ -311,7 +377,20 @@ fn text_xpaths(text: &str) -> [String; 4] {
311
377
  //textarea[{by_placeholder} or {by_label}]"
312
378
  )
313
379
  };
314
- [
380
+ // Fields addressed by their <label>: the wrapping form associates by
381
+ // containment, the `for` form by id. XPath 1.0 node-set comparison
382
+ // makes `@id = //label[…]/@for` "any label whose for equals this id".
383
+ let by_label_assoc = |label_text: String| {
384
+ ["input", "textarea", "select"]
385
+ .map(|tag| {
386
+ format!(
387
+ "//label[{label_text}]//{tag} | \
388
+ //{tag}[@id = //label[{label_text}]/@for]"
389
+ )
390
+ })
391
+ .join(" | ")
392
+ };
393
+ vec![
315
394
  build(
316
395
  format!("text()[normalize-space(.)={lit}]"),
317
396
  format!("@aria-label={lit}"),
@@ -322,6 +401,7 @@ fn text_xpaths(text: &str) -> [String; 4] {
322
401
  format!("@aria-label={lit}"),
323
402
  format!("@placeholder={lit}"),
324
403
  ),
404
+ by_label_assoc(format!("normalize-space()={lit}")),
325
405
  build(
326
406
  format!("text()[starts-with(normalize-space(.), {lit})]"),
327
407
  format!("starts-with(@aria-label, {lit})"),
@@ -332,6 +412,25 @@ fn text_xpaths(text: &str) -> [String; 4] {
332
412
  format!("starts-with(@aria-label, {lit})"),
333
413
  format!("starts-with(@placeholder, {lit})"),
334
414
  ),
415
+ by_label_assoc(format!("starts-with(normalize-space(), {lit})")),
416
+ format!(
417
+ "{} | {}",
418
+ build(
419
+ ci("normalize-space()"),
420
+ ci("@aria-label"),
421
+ ci("@placeholder"),
422
+ ),
423
+ by_label_assoc(ci("normalize-space()")),
424
+ ),
425
+ format!(
426
+ "{} | {}",
427
+ build(
428
+ ci_prefix("normalize-space()"),
429
+ ci_prefix("@aria-label"),
430
+ ci_prefix("@placeholder"),
431
+ ),
432
+ by_label_assoc(ci_prefix("normalize-space()")),
433
+ ),
335
434
  ]
336
435
  }
337
436
 
@@ -368,6 +467,17 @@ impl AppDriver for WebAppDriver {
368
467
  _window_name: &str,
369
468
  _timeout: Duration,
370
469
  ) -> Result<(), DriverError> {
470
+ let staged_browser = self.staged_browser.take();
471
+ // Extra Chrome flags only apply at process start: swap in a
472
+ // PRIVATE browser for this flow (a plain tab on it is already
473
+ // isolated), paying its cold start instead of sharing.
474
+ if let Some(config) = &staged_browser {
475
+ if !config.args.is_empty() {
476
+ self.browser =
477
+ launch_browser(&config.args).map_err(|e| DriverError::Uia(e.to_string()))?;
478
+ self.context_id = None;
479
+ }
480
+ }
371
481
  // On the shared browser, open the tab INSIDE this flow's isolated
372
482
  // context so its cookies/storage never touch another flow's.
373
483
  let tab = match &self.context_id {
@@ -388,9 +498,146 @@ impl AppDriver for WebAppDriver {
388
498
  None => self.browser.new_tab(),
389
499
  }
390
500
  .map_err(|e| web_err("opening tab", e))?;
501
+ // Viewport/UA emulation BEFORE navigation, so the app boots into
502
+ // the emulated device (responsive breakpoints, UA sniffing). NOT
503
+ // best-effort: a flow recorded mobile must never run desktop.
504
+ if let Some(config) = &staged_browser {
505
+ if let Some(vp) = &config.viewport {
506
+ tab.call_method(Emulation::SetDeviceMetricsOverride {
507
+ width: vp.width,
508
+ height: vp.height,
509
+ device_scale_factor: vp.device_scale_factor,
510
+ mobile: vp.mobile,
511
+ scale: None,
512
+ screen_width: None,
513
+ screen_height: None,
514
+ position_x: None,
515
+ position_y: None,
516
+ dont_set_visible_size: None,
517
+ screen_orientation: None,
518
+ viewport: None,
519
+ display_feature: None,
520
+ device_posture: None,
521
+ })
522
+ .map_err(|e| web_err("emulating viewport", e))?;
523
+ if vp.touch {
524
+ tab.call_method(Emulation::SetTouchEmulationEnabled {
525
+ enabled: true,
526
+ max_touch_points: Some(1),
527
+ })
528
+ .map_err(|e| web_err("emulating touch", e))?;
529
+ }
530
+ }
531
+ if let Some(ua) = &config.user_agent {
532
+ tab.set_user_agent(ua, None, None)
533
+ .map_err(|e| web_err("overriding user agent", e))?;
534
+ }
535
+ }
391
536
  if let Some(session) = self.staged_session.take() {
392
537
  Self::apply_session(&tab, &session, command)?;
393
538
  }
539
+ // Network mocks: install interception BEFORE navigation so even the
540
+ // first document's subresources are answerable. Unlike the console
541
+ // listener this is NOT best-effort — a mock that silently failed to
542
+ // install would change what the flow tests.
543
+ if !self.staged_mocks.is_empty() {
544
+ let mocks = std::mem::take(&mut self.staged_mocks);
545
+ tab.enable_fetch(None, None)
546
+ .map_err(|e| web_err("enabling network interception", e))?;
547
+ tab.enable_request_interception(Arc::new(
548
+ move |_transport: Arc<headless_chrome::browser::transport::Transport>,
549
+ _session: headless_chrome::browser::transport::SessionId,
550
+ event: headless_chrome::protocol::cdp::Fetch::events::RequestPausedEvent| {
551
+ use headless_chrome::browser::tab::RequestPausedDecision;
552
+ use headless_chrome::protocol::cdp::Fetch;
553
+ let url = &event.params.request.url;
554
+ let method = event.params.request.method.to_ascii_uppercase();
555
+ // Mocked responses must carry permissive CORS headers:
556
+ // the page's origin differs from the mocked host, and a
557
+ // fulfilled response is still subject to CORS — without
558
+ // them the fetch rejects and the mock looks dead.
559
+ let cors = |ct: Option<&str>| {
560
+ let mut headers = vec![
561
+ Fetch::HeaderEntry {
562
+ name: "access-control-allow-origin".into(),
563
+ value: "*".into(),
564
+ },
565
+ Fetch::HeaderEntry {
566
+ name: "access-control-allow-methods".into(),
567
+ value: "*".into(),
568
+ },
569
+ Fetch::HeaderEntry {
570
+ name: "access-control-allow-headers".into(),
571
+ value: "*".into(),
572
+ },
573
+ ];
574
+ if let Some(ct) = ct {
575
+ headers.push(Fetch::HeaderEntry {
576
+ name: "content-type".into(),
577
+ value: ct.to_string(),
578
+ });
579
+ }
580
+ headers
581
+ };
582
+ let any_match = mocks.iter().any(|m| url.contains(&m.url_contains));
583
+ // CORS preflight for a mocked URL: answer it ourselves —
584
+ // the real host may not even exist.
585
+ if method == "OPTIONS" && any_match {
586
+ return RequestPausedDecision::Fulfill(Fetch::FulfillRequest {
587
+ request_id: event.params.request_id.clone(),
588
+ response_code: 204,
589
+ response_headers: Some(cors(None)),
590
+ binary_response_headers: None,
591
+ body: None,
592
+ response_phrase: None,
593
+ });
594
+ }
595
+ let rule = mocks.iter().find(|m| {
596
+ url.contains(&m.url_contains)
597
+ && m.method.as_ref().is_none_or(|want| *want == method)
598
+ });
599
+ match rule {
600
+ Some(m) => RequestPausedDecision::Fulfill(Fetch::FulfillRequest {
601
+ request_id: event.params.request_id.clone(),
602
+ response_code: u32::from(m.status),
603
+ response_headers: Some(cors(Some(&m.content_type))),
604
+ binary_response_headers: None,
605
+ body: Some(base64_encode(&m.body)),
606
+ response_phrase: None,
607
+ }),
608
+ None => RequestPausedDecision::Continue(None),
609
+ }
610
+ },
611
+ ))
612
+ .map_err(|e| web_err("installing network mocks", e))?;
613
+ }
614
+ // Console tail: subscribe BEFORE navigation so boot-time errors are
615
+ // captured too. Best-effort — a page without console history still
616
+ // yields a DOM snapshot on failure.
617
+ if tab.enable_log().and_then(|t| t.enable_runtime()).is_ok() {
618
+ let buffer = self.console.clone();
619
+ let listener = move |event: &headless_chrome::protocol::cdp::types::Event| {
620
+ use headless_chrome::protocol::cdp::types::Event;
621
+ let line = match event {
622
+ Event::LogEntryAdded(e) => Some(format!(
623
+ "[{:?}] {}",
624
+ e.params.entry.level, e.params.entry.text
625
+ )),
626
+ Event::RuntimeExceptionThrown(e) => {
627
+ Some(format!("[exception] {}", e.params.exception_details.text))
628
+ }
629
+ _ => None,
630
+ };
631
+ if let Some(line) = line {
632
+ let mut buf = buffer.lock().unwrap_or_else(|e| e.into_inner());
633
+ if buf.len() >= CONSOLE_TAIL_CAP {
634
+ buf.pop_front();
635
+ }
636
+ buf.push_back(line);
637
+ }
638
+ };
639
+ tab.add_event_listener(Arc::new(listener)).ok();
640
+ }
394
641
  tab.navigate_to(command)
395
642
  .map_err(|e| web_err(&format!("navigating to {command}"), e))?;
396
643
  tab.wait_until_navigated()
@@ -399,10 +646,47 @@ impl AppDriver for WebAppDriver {
399
646
  Ok(())
400
647
  }
401
648
 
649
+ fn debug_bundle(&mut self) -> Result<Option<flowproof_driver::DebugBundle>, DriverError> {
650
+ // Best-effort by contract: a half-captured bundle still beats none.
651
+ let dom_html = self
652
+ .tab()
653
+ .ok()
654
+ .and_then(|tab| {
655
+ tab.evaluate("document.documentElement.outerHTML", false)
656
+ .ok()
657
+ })
658
+ .and_then(|v| v.value)
659
+ .and_then(|v| v.as_str().map(str::to_string));
660
+ let console = self
661
+ .console
662
+ .lock()
663
+ .unwrap_or_else(|e| e.into_inner())
664
+ .iter()
665
+ .cloned()
666
+ .collect();
667
+ Ok(Some(flowproof_driver::DebugBundle { dom_html, console }))
668
+ }
669
+
402
670
  fn surface_text(&mut self) -> Result<String, DriverError> {
671
+ // Visible text PLUS the accessible names of visible elements:
672
+ // icon-only buttons (a command palette, an account menu) exist on
673
+ // the page only as aria-labels, and `page shows` must see them.
403
674
  let value = self
404
675
  .tab()?
405
- .evaluate("document.body ? document.body.innerText : ''", false)
676
+ .evaluate(
677
+ r#"(() => {
678
+ const text = document.body ? document.body.innerText : '';
679
+ const names = [];
680
+ for (const el of document.querySelectorAll('[aria-label]')) {
681
+ const r = el.getBoundingClientRect();
682
+ if (r.width > 0 && r.height > 0) {
683
+ names.push(el.getAttribute('aria-label'));
684
+ }
685
+ }
686
+ return names.length ? text + '\n' + names.join('\n') : text;
687
+ })()"#,
688
+ false,
689
+ )
406
690
  .map_err(|e| web_err("reading page text", e))?;
407
691
  Ok(value
408
692
  .value
@@ -415,6 +699,19 @@ impl AppDriver for WebAppDriver {
415
699
  Ok(())
416
700
  }
417
701
 
702
+ fn stage_mocks(&mut self, rules: Vec<flowproof_driver::WebMock>) -> Result<(), DriverError> {
703
+ self.staged_mocks = rules;
704
+ Ok(())
705
+ }
706
+
707
+ fn stage_browser(
708
+ &mut self,
709
+ config: flowproof_driver::WebBrowserConfig,
710
+ ) -> Result<(), DriverError> {
711
+ self.staged_browser = Some(config);
712
+ Ok(())
713
+ }
714
+
418
715
  fn navigate(&mut self, url: &str) -> Result<(), DriverError> {
419
716
  let tab = self.tab()?;
420
717
  tab.navigate_to(url)
@@ -460,6 +757,31 @@ impl AppDriver for WebAppDriver {
460
757
  Ok(value.value.and_then(|v| v.as_bool()).unwrap_or(true))
461
758
  }
462
759
 
760
+ fn element_receives_events(
761
+ &mut self,
762
+ selector: &UiaSelector,
763
+ ) -> Result<Option<bool>, DriverError> {
764
+ let locator = Self::locator(selector)?;
765
+ let value =
766
+ self.with_element(&locator, &format!("hit-testing [{selector}]"), |element| {
767
+ // Playwright's obscured check: does elementFromPoint at the
768
+ // element's center resolve to it (or a relative)? A toast or
769
+ // modal backdrop on top makes the click land elsewhere.
770
+ element.call_js_fn(
771
+ r#"function() {
772
+ const r = this.getBoundingClientRect();
773
+ if (r.width === 0 || r.height === 0) { return false; }
774
+ const t = document.elementFromPoint(
775
+ r.x + r.width / 2, r.y + r.height / 2);
776
+ return !!(t && (t === this || this.contains(t) || t.contains(this)));
777
+ }"#,
778
+ vec![],
779
+ false,
780
+ )
781
+ })?;
782
+ Ok(value.value.and_then(|v| v.as_bool()))
783
+ }
784
+
463
785
  fn invoke(&mut self, selector: &UiaSelector) -> Result<(), DriverError> {
464
786
  let locator = Self::locator(selector)?;
465
787
  self.with_element(&locator, &format!("clicking [{selector}]"), |element| {
@@ -467,6 +789,64 @@ impl AppDriver for WebAppDriver {
467
789
  })
468
790
  }
469
791
 
792
+ fn set_files(&mut self, selector: &UiaSelector, paths: &[String]) -> Result<(), DriverError> {
793
+ // Absolute paths: Chrome resolves DOM.setFileInputFiles against ITS
794
+ // working directory, not ours — canonicalize (which also fails
795
+ // loudly on a missing file, before the step "passes" emptily).
796
+ let mut absolute = Vec::with_capacity(paths.len());
797
+ for path in paths {
798
+ let canonical = std::fs::canonicalize(path)
799
+ .map_err(|e| web_err(&format!("upload file '{path}'"), e))?;
800
+ absolute.push(canonical.to_string_lossy().into_owned());
801
+ }
802
+ let locator = Self::locator(selector)?;
803
+ self.with_element(
804
+ &locator,
805
+ &format!("setting files on [{selector}]"),
806
+ |element| {
807
+ let refs: Vec<&str> = absolute.iter().map(String::as_str).collect();
808
+ element.set_input_files(&refs).map(|_| ())
809
+ },
810
+ )
811
+ }
812
+
813
+ fn context_click(&mut self, selector: &UiaSelector) -> Result<(), DriverError> {
814
+ let locator = Self::locator(selector)?;
815
+ let tab = self.tab()?.clone();
816
+ self.with_element(
817
+ &locator,
818
+ &format!("right-clicking [{selector}]"),
819
+ |element| {
820
+ element.scroll_into_view()?;
821
+ let point = element.get_midpoint()?;
822
+ for kind in [
823
+ Input::DispatchMouseEventTypeOption::MousePressed,
824
+ Input::DispatchMouseEventTypeOption::MouseReleased,
825
+ ] {
826
+ tab.call_method(Input::DispatchMouseEvent {
827
+ Type: kind,
828
+ x: point.x,
829
+ y: point.y,
830
+ button: Some(Input::MouseButton::Right),
831
+ click_count: Some(1),
832
+ modifiers: None,
833
+ timestamp: None,
834
+ buttons: None,
835
+ force: None,
836
+ tangential_pressure: None,
837
+ tilt_x: None,
838
+ tilt_y: None,
839
+ twist: None,
840
+ delta_x: None,
841
+ delta_y: None,
842
+ pointer_Type: None,
843
+ })?;
844
+ }
845
+ Ok(())
846
+ },
847
+ )
848
+ }
849
+
470
850
  fn read_text(&mut self, selector: &UiaSelector) -> Result<String, DriverError> {
471
851
  let locator = Self::locator(selector)?;
472
852
  // Inner text covers most elements; inputs expose their VALUE — the
@@ -680,3 +1060,49 @@ impl AppDriver for WebAppDriver {
680
1060
  Ok(rects)
681
1061
  }
682
1062
  }
1063
+
1064
+ #[cfg(test)]
1065
+ mod tests {
1066
+ #[test]
1067
+ fn text_xpath_ladder_orders_exact_label_prefix_then_case_insensitive() {
1068
+ let rungs = super::text_xpaths("Close Account");
1069
+ assert_eq!(rungs.len(), 8);
1070
+ // Rung 1: exact own-text — unchanged from the original ladder.
1071
+ assert!(rungs[0].contains("text()[normalize-space(.)='Close Account']"));
1072
+ // Rung 3: label association — wrapping form and for/id pairing.
1073
+ assert!(rungs[2].contains("//label[normalize-space()='Close Account']//input"));
1074
+ assert!(rungs[2].contains("//input[@id = //label[normalize-space()='Close Account']/@for]"));
1075
+ assert!(rungs[2].contains("//select"));
1076
+ // Rung 6: label prefix — `Name` finds the field labelled `Name:`.
1077
+ assert!(rungs[5].contains("starts-with(normalize-space(), 'Close Account')"));
1078
+ // Rungs 7-8: case-insensitive fallbacks compare lowercased text.
1079
+ assert!(rungs[6].contains("translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='close account'"));
1080
+ assert!(rungs[6].contains("translate(@aria-label"));
1081
+ assert!(rungs[7].contains("starts-with(translate(normalize-space()"));
1082
+ // No case-sensitive rung mentions translate: exact always wins.
1083
+ for rung in &rungs[..6] {
1084
+ assert!(
1085
+ !rung.contains("translate("),
1086
+ "case-sensitive rung uses translate: {rung}"
1087
+ );
1088
+ }
1089
+ }
1090
+
1091
+ #[test]
1092
+ fn base64_matches_the_standard_alphabet_and_padding() {
1093
+ // RFC 4648 vectors.
1094
+ for (input, want) in [
1095
+ (&b""[..], ""),
1096
+ (b"f", "Zg=="),
1097
+ (b"fo", "Zm8="),
1098
+ (b"foo", "Zm9v"),
1099
+ (b"foob", "Zm9vYg=="),
1100
+ (b"fooba", "Zm9vYmE="),
1101
+ (b"foobar", "Zm9vYmFy"),
1102
+ ] {
1103
+ assert_eq!(super::base64_encode(input), want);
1104
+ }
1105
+ // Binary-safe (high bytes map into +/ territory).
1106
+ assert_eq!(super::base64_encode(&[0xfb, 0xff, 0xfe]), "+//+");
1107
+ }
1108
+ }
@@ -15,7 +15,9 @@ pub mod spec;
15
15
  pub use clarify::{Clarification, ClarifyStage};
16
16
  pub use heal::{heal, heal_with_author, HealError, HealReport};
17
17
  pub use llm::{HttpModelClient, ModelClient};
18
- pub use recorder::{record, record_with_author, Author, RecordError, RecordSummary};
18
+ pub use recorder::{
19
+ record, record_incremental, record_with_author, Author, RecordError, RecordSummary,
20
+ };
19
21
  pub use spec::{FlowSpec, SpecStep, SuiteManifest};
20
22
 
21
23
  use std::env;