flowproof 0.2.3__tar.gz → 0.2.5__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 (67) hide show
  1. {flowproof-0.2.3 → flowproof-0.2.5}/Cargo.lock +7 -7
  2. {flowproof-0.2.3 → flowproof-0.2.5}/Cargo.toml +1 -1
  3. {flowproof-0.2.3 → flowproof-0.2.5}/PKG-INFO +1 -1
  4. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-adapters/src/web.rs +76 -8
  5. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-agent/src/recorder.rs +38 -10
  6. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-agent/src/rules.rs +200 -25
  7. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/src/lib.rs +173 -24
  8. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/suite_env_from.rs +96 -0
  9. flowproof-0.2.5/crates/flowproof-cli/tests/suite_flow_isolation.rs +93 -0
  10. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/suite_missing_trace.rs +10 -2
  11. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/web_e2e.rs +173 -0
  12. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/app.rs +178 -0
  13. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/lib.rs +23 -3
  14. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/mock.rs +35 -0
  15. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-replay/src/lib.rs +171 -20
  16. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-replay/src/report.rs +79 -7
  17. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-replay/tests/replay_calc.rs +271 -0
  18. {flowproof-0.2.3 → flowproof-0.2.5}/flowproof/__init__.py +1 -1
  19. {flowproof-0.2.3 → flowproof-0.2.5}/pyproject.toml +1 -1
  20. {flowproof-0.2.3 → flowproof-0.2.5}/README.md +0 -0
  21. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-adapters/Cargo.toml +0 -0
  22. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-adapters/src/lib.rs +0 -0
  23. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-adapters/src/sap_com.rs +0 -0
  24. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-adapters/src/vision.rs +0 -0
  25. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-agent/Cargo.toml +0 -0
  26. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-agent/src/author.rs +0 -0
  27. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-agent/src/clarify.rs +0 -0
  28. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-agent/src/heal.rs +0 -0
  29. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-agent/src/lib.rs +0 -0
  30. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-agent/src/llm.rs +0 -0
  31. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-agent/src/spec.rs +0 -0
  32. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/Cargo.toml +0 -0
  33. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/src/main.rs +0 -0
  34. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  35. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  36. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
  37. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  38. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  39. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  40. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  41. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  42. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  43. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  44. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  45. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  46. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/Cargo.toml +0 -0
  47. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/backend.rs +0 -0
  48. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/gdi.rs +0 -0
  49. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/oob.rs +0 -0
  50. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/recording.rs +0 -0
  51. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/redact.rs +0 -0
  52. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/visual.rs +0 -0
  53. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-driver/src/window.rs +0 -0
  54. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-python/Cargo.toml +0 -0
  55. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-python/src/lib.rs +0 -0
  56. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-replay/Cargo.toml +0 -0
  57. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-trace/Cargo.toml +0 -0
  58. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  59. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-trace/src/format.rs +0 -0
  60. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-trace/src/lib.rs +0 -0
  61. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-trace/src/secret.rs +0 -0
  62. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  63. {flowproof-0.2.3 → flowproof-0.2.5}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  64. {flowproof-0.2.3 → flowproof-0.2.5}/flowproof/cli.py +0 -0
  65. {flowproof-0.2.3 → flowproof-0.2.5}/flowproof/flow.py +0 -0
  66. {flowproof-0.2.3 → flowproof-0.2.5}/flowproof/mcp_server.py +0 -0
  67. {flowproof-0.2.3 → flowproof-0.2.5}/flowproof/py.typed +0 -0
@@ -722,7 +722,7 @@ dependencies = [
722
722
 
723
723
  [[package]]
724
724
  name = "flowproof-adapters"
725
- version = "0.2.3"
725
+ version = "0.2.5"
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.3"
741
+ version = "0.2.5"
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.3"
756
+ version = "0.2.5"
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.3"
774
+ version = "0.2.5"
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.3"
788
+ version = "0.2.5"
789
789
  dependencies = [
790
790
  "flowproof-agent",
791
791
  "flowproof-cli",
@@ -797,7 +797,7 @@ dependencies = [
797
797
 
798
798
  [[package]]
799
799
  name = "flowproof-replay"
800
- version = "0.2.3"
800
+ version = "0.2.5"
801
801
  dependencies = [
802
802
  "chrono",
803
803
  "flowproof-agent",
@@ -811,7 +811,7 @@ dependencies = [
811
811
 
812
812
  [[package]]
813
813
  name = "flowproof-trace"
814
- version = "0.2.3"
814
+ version = "0.2.5"
815
815
  dependencies = [
816
816
  "jsonschema",
817
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.3"
6
+ version = "0.2.5"
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.3
3
+ Version: 0.2.5
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -19,16 +19,64 @@ use crate::AdapterError;
19
19
 
20
20
  const FIND_TIMEOUT: Duration = Duration::from_secs(5);
21
21
 
22
+ /// Wrap a browser-driver failure. Transport faults (a dead CDP websocket,
23
+ /// a dropped event) are classified apart from app observations: an
24
+ /// assertion polling inside its recorded wait budget tolerates the former
25
+ /// as a miss, because a call that never reached the page learned nothing
26
+ /// about it.
22
27
  fn web_err(context: &str, err: impl std::fmt::Display) -> DriverError {
23
- DriverError::Uia(format!("web: {context}: {err}"))
28
+ let message = format!("{context}: {err}");
29
+ if is_transport_fault(&message) {
30
+ DriverError::Transport(message)
31
+ } else {
32
+ DriverError::Browser(message)
33
+ }
24
34
  }
25
35
 
36
+ /// How long the CDP transport may sit without a BROWSER-level event before
37
+ /// headless_chrome reaps its listener thread. Its default is 30 seconds,
38
+ /// which is a live grenade for real test flows:
39
+ ///
40
+ /// 1. a flow spends 30+ seconds doing page-level work (typing, polling an
41
+ /// auto-waiting assertion) without producing a single browser-level
42
+ /// event, so the listener thread times out and exits;
43
+ /// 2. the next navigation fires `TargetInfoChanged` - a browser-level
44
+ /// event - and the transport cannot deliver it to the receiver that
45
+ /// just went away;
46
+ /// 3. it treats that undeliverable event as fatal, shuts the whole message
47
+ /// loop down, and every later call fails with "Unable to make method
48
+ /// calls because underlying connection is closed", permanently.
49
+ ///
50
+ /// That is the entire mechanism behind the round-3 field blocker: EVERY
51
+ /// flow that logged in recorded fine and then failed to replay, because
52
+ /// the login redirect is exactly a post-idle navigation. Silence is not
53
+ /// evidence of a dead browser - a browser that actually dies closes the
54
+ /// socket, which surfaces immediately and through a different path.
55
+ ///
56
+ /// Choosing the value is a genuine trade-off, because headless_chrome
57
+ /// OVERLOADS this one knob across three jobs with opposite needs:
58
+ ///
59
+ /// - `Browser`'s event-listener reap and the transport's idle reap want it
60
+ /// LONG (that is the bug above);
61
+ /// - `Transport::call_method` uses it as the bound on waiting for a call's
62
+ /// RESPONSE, which wants it SHORT: a response that never arrives blocks
63
+ /// for exactly this long. Setting it to "effectively never" turned a
64
+ /// failing CI job into one that hung for over three hours.
65
+ ///
66
+ /// So: comfortably longer than any real gap between browser-level events
67
+ /// (the field flows idled 30-90 s; `Wait until` defaults to a 60 s bound),
68
+ /// and short enough that a lost response fails visibly instead of hanging.
69
+ /// A flow that deliberately waits longer than this in one step without
70
+ /// touching the browser is the case to revisit if it ever shows up.
71
+ const BROWSER_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
72
+
26
73
  /// Launch a fresh headless Chromium (`CHROME` env var overrides the
27
74
  /// binary), optionally with extra command-line flags.
28
75
  fn launch_browser(extra_args: &[String]) -> Result<Browser, AdapterError> {
29
76
  let os_args: Vec<std::ffi::OsString> = extra_args.iter().map(Into::into).collect();
30
77
  let mut options = LaunchOptions::default_builder();
31
78
  options.headless(true).sandbox(false);
79
+ options.idle_browser_timeout(BROWSER_IDLE_TIMEOUT);
32
80
  options.args(os_args.iter().map(AsRef::as_ref).collect());
33
81
  if let Ok(path) = std::env::var("CHROME") {
34
82
  options.path(Some(path.into()));
@@ -210,7 +258,7 @@ impl WebAppDriver {
210
258
  fn tab(&self) -> Result<&Arc<Tab>, DriverError> {
211
259
  self.tab
212
260
  .as_ref()
213
- .ok_or_else(|| DriverError::Uia("web: no page open: call launch first".into()))
261
+ .ok_or_else(|| DriverError::Browser("no page open: call launch first".into()))
214
262
  }
215
263
 
216
264
  fn locator_of(selector: &UiaSelector) -> Option<WebLocator> {
@@ -234,8 +282,8 @@ impl WebAppDriver {
234
282
 
235
283
  fn locator(selector: &UiaSelector) -> Result<WebLocator, DriverError> {
236
284
  Self::locator_of(selector).ok_or_else(|| {
237
- DriverError::Uia(format!(
238
- "web: selector [{selector}] has no css, automation_id, or text"
285
+ DriverError::Browser(format!(
286
+ "selector [{selector}] has no css, automation_id, or text"
239
287
  ))
240
288
  })
241
289
  }
@@ -279,7 +327,7 @@ impl WebAppDriver {
279
327
  return Ok(element);
280
328
  }
281
329
  if std::time::Instant::now() >= deadline {
282
- return Err(DriverError::Uia(format!("web: no element for {locator}")));
330
+ return Err(DriverError::Browser(format!("no element for {locator}")));
283
331
  }
284
332
  std::thread::sleep(Duration::from_millis(100));
285
333
  }
@@ -473,8 +521,8 @@ impl AppDriver for WebAppDriver {
473
521
  // isolated), paying its cold start instead of sharing.
474
522
  if let Some(config) = &staged_browser {
475
523
  if !config.args.is_empty() {
476
- self.browser =
477
- launch_browser(&config.args).map_err(|e| DriverError::Uia(e.to_string()))?;
524
+ self.browser = launch_browser(&config.args)
525
+ .map_err(|e| DriverError::Browser(e.to_string()))?;
478
526
  self.context_id = None;
479
527
  }
480
528
  }
@@ -667,6 +715,17 @@ impl AppDriver for WebAppDriver {
667
715
  Ok(Some(flowproof_driver::DebugBundle { dom_html, console }))
668
716
  }
669
717
 
718
+ fn current_url(&mut self) -> Result<String, DriverError> {
719
+ let value = self
720
+ .tab()?
721
+ .evaluate("window.location.href", false)
722
+ .map_err(|e| web_err("reading page url", e))?;
723
+ Ok(value
724
+ .value
725
+ .and_then(|v| v.as_str().map(str::to_string))
726
+ .unwrap_or_default())
727
+ }
728
+
670
729
  fn surface_text(&mut self) -> Result<String, DriverError> {
671
730
  // Visible text PLUS the accessible names of visible elements:
672
731
  // icon-only buttons (a command palette, an account menu) exist on
@@ -764,6 +823,15 @@ impl AppDriver for WebAppDriver {
764
823
  let locator = Self::locator(selector)?;
765
824
  let value =
766
825
  self.with_element(&locator, &format!("hit-testing [{selector}]"), |element| {
826
+ // Scroll first, exactly as the click itself will: headless
827
+ // chrome's `Element::click` begins with `scroll_into_view`,
828
+ // so hit-testing before scrolling asks about a position the
829
+ // click will never use. An element below the fold then reads
830
+ // as "obscured" - `elementFromPoint` outside the viewport
831
+ // returns null - and the gate blocks a click that would have
832
+ // worked. A whole settings form under the fold was
833
+ // untestable this way (field report, round 3).
834
+ element.scroll_into_view()?;
767
835
  // Playwright's obscured check: does elementFromPoint at the
768
836
  // element's center resolve to it (or a relative)? A toast or
769
837
  // modal backdrop on top makes the click land elsewhere.
@@ -1035,7 +1103,7 @@ impl AppDriver for WebAppDriver {
1035
1103
  let json = value
1036
1104
  .value
1037
1105
  .and_then(|v| v.as_str().map(str::to_string))
1038
- .ok_or_else(|| DriverError::Uia("web: scene script returned no value".into()))?;
1106
+ .ok_or_else(|| DriverError::Browser("scene script returned no value".into()))?;
1039
1107
  Ok(Some(json))
1040
1108
  }
1041
1109
 
@@ -334,6 +334,8 @@ fn step_for(id: usize, intent: &str, app: &str, action: &ResolvedAction) -> Step
334
334
  TextMatch::NumericEquals => {
335
335
  serde_json::json!({ "value_equals": expected, "normalize": "numeric" })
336
336
  }
337
+ TextMatch::UrlEquals => serde_json::json!({ "url_equals": expected }),
338
+ TextMatch::UrlContains => serde_json::json!({ "url_contains": expected }),
337
339
  };
338
340
  expect["timeout_ms"] = serde_json::json!(timeout_ms);
339
341
  let selectors = selectors_for(app, target, None);
@@ -896,15 +898,36 @@ fn poll_oob(
896
898
  }
897
899
 
898
900
  fn assert_holds(actual: &str, expected: &str, matcher: TextMatch) -> bool {
901
+ // Case-insensitive FALLBACK, mirroring element anchors: an exact
902
+ // match always wins; when it misses, lowercased comparison decides
903
+ // ("page shows Close Account" against a page reading "Close
904
+ // account").
905
+ //
906
+ // Widening-only, and it must stay byte-identical to replay's
907
+ // `text_matches`: if record and replay disagree about a single
908
+ // assertion, record mints a trace that cannot replay - the exact
909
+ // failure mode the round-3 field run was built to catch. The negative
910
+ // form therefore does NOT take the fallback (widening it would fail
911
+ // traces that used to pass), and a nonzero case-sensitive count is
912
+ // the count.
913
+ let (actual_ci, expected_ci) = (actual.to_lowercase(), expected.to_lowercase());
899
914
  match matcher {
900
- TextMatch::Contains => actual.contains(expected),
915
+ TextMatch::Contains => actual.contains(expected) || actual_ci.contains(&expected_ci),
901
916
  TextMatch::NotContains => !actual.contains(expected),
902
- TextMatch::CountEquals(n) => actual.matches(expected).count() as u64 == n,
903
- TextMatch::Equals => actual == expected,
917
+ TextMatch::CountEquals(n) => {
918
+ let sensitive = actual.matches(expected).count() as u64;
919
+ sensitive == n
920
+ || (sensitive == 0 && actual_ci.matches(&expected_ci).count() as u64 == n)
921
+ }
922
+ TextMatch::Equals => actual == expected || actual_ci == expected_ci,
904
923
  TextMatch::NumericEquals => matches!(
905
924
  (flowproof_driver::numeric_value(actual), expected.parse::<f64>()),
906
925
  (Some(a), Ok(e)) if a == e
907
926
  ),
927
+ // Shared with replay so record and replay cannot drift: a URL
928
+ // assertion that holds while recording must hold when replayed.
929
+ TextMatch::UrlEquals => flowproof_driver::url_matches(expected, true, actual),
930
+ TextMatch::UrlContains => flowproof_driver::url_matches(expected, false, actual),
908
931
  }
909
932
  }
910
933
 
@@ -1237,13 +1260,18 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1237
1260
  let wanted = flowproof_trace::secret::resolve_refs(expected)?;
1238
1261
  let deadline = std::time::Instant::now() + Duration::from_millis(*timeout_ms);
1239
1262
  loop {
1240
- let actual = if selector.is_none() {
1241
- Some(driver.surface_text()?)
1242
- } else if driver.element_exists(targeted())? {
1243
- Some(driver.read_text(targeted())?)
1244
- } else {
1245
- None
1246
- };
1263
+ let actual =
1264
+ if matches!(matcher, TextMatch::UrlEquals | TextMatch::UrlContains) {
1265
+ // The URL is a different reading of the surface,
1266
+ // not a different target: same poll, same bound.
1267
+ Some(driver.current_url()?)
1268
+ } else if selector.is_none() {
1269
+ Some(driver.surface_text()?)
1270
+ } else if driver.element_exists(targeted())? {
1271
+ Some(driver.read_text(targeted())?)
1272
+ } else {
1273
+ None
1274
+ };
1247
1275
  if let Some(actual) = &actual {
1248
1276
  if assert_holds(actual, &wanted, *matcher) {
1249
1277
  break;
@@ -164,6 +164,12 @@ pub enum TextMatch {
164
164
  Equals,
165
165
  /// Compare the trailing numeric value instead of raw text.
166
166
  NumericEquals,
167
+ /// The surface's URL equals the expectation - `page url is /signin`.
168
+ /// See `url_matches` in flowproof-replay for what "equals" means for a
169
+ /// path, a path with a query, and a whole URL.
170
+ UrlEquals,
171
+ /// The surface's URL contains the expectation - `page url contains x`.
172
+ UrlContains,
167
173
  }
168
174
 
169
175
  /// Default auto-wait for assertions (Playwright's expect default).
@@ -175,11 +181,13 @@ pub const WAIT_STEP_TIMEOUT_MS: u64 = 60_000;
175
181
  /// Parse a trailing `within <N>s` / `within <N> seconds` qualifier off a
176
182
  /// step, returning (rest, timeout override).
177
183
  fn split_within(text: &str) -> (&str, Option<u64>) {
178
- let lower = text.to_lowercase();
179
- let Some(pos) = lower.rfind(" within ") else {
184
+ // rfind_ci returns indices valid in `text` itself — never positions
185
+ // from a lowercased copy, whose byte offsets can differ.
186
+ let Some(pos) = rfind_ci(text, " within ") else {
180
187
  return (text, None);
181
188
  };
182
- let qualifier = lower[pos + " within ".len()..].trim();
189
+ let qualifier = text[pos + " within ".len()..].trim().to_lowercase();
190
+ let qualifier = qualifier.as_str();
183
191
  let digits = qualifier
184
192
  .strip_suffix(" seconds")
185
193
  .or_else(|| qualifier.strip_suffix(" second"))
@@ -207,29 +215,68 @@ fn unresolvable(step: &str, reason: impl Into<String>) -> RulesError {
207
215
  }
208
216
 
209
217
  /// Case-insensitively strip an ASCII `prefix`, returning the rest of the
210
- /// ORIGINAL string (case preserved).
218
+ /// ORIGINAL string (case preserved). Boundary-safe: `get` returns None
219
+ /// when the cut would split a multibyte char, and an ASCII prefix cannot
220
+ /// match across one — so "no match" is the correct answer, never a panic.
211
221
  fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
212
- if text.len() >= prefix.len() && text[..prefix.len()].eq_ignore_ascii_case(prefix) {
213
- Some(&text[prefix.len()..])
214
- } else {
215
- None
216
- }
222
+ let head = text.get(..prefix.len())?;
223
+ head.eq_ignore_ascii_case(prefix)
224
+ .then(|| &text[prefix.len()..])
217
225
  }
218
226
 
219
227
  /// Case-insensitively strip an ASCII `suffix`, returning the front of the
220
- /// ORIGINAL string (case preserved).
228
+ /// ORIGINAL string (case preserved). Boundary-safe like `strip_prefix_ci`:
229
+ /// `text.len() - suffix.len()` is a byte offset that can land inside a
230
+ /// multibyte char (`4 × 5=20` with suffix ` times`), which must mean "no
231
+ /// match", not a slice panic — the 0.2.3 escape.
221
232
  fn strip_suffix_ci<'a>(text: &'a str, suffix: &str) -> Option<&'a str> {
222
- if text.len() >= suffix.len() && text[text.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
223
- {
224
- Some(&text[..text.len() - suffix.len()])
225
- } else {
226
- None
227
- }
233
+ let split = text.len().checked_sub(suffix.len())?;
234
+ let tail = text.get(split..)?;
235
+ tail.eq_ignore_ascii_case(suffix).then(|| &text[..split])
236
+ }
237
+
238
+ /// Byte index of the last case-insensitive occurrence of ASCII `needle`
239
+ /// in `text`, ALWAYS valid for slicing `text` itself. The
240
+ /// `text.to_lowercase().find(…)` idiom is the sibling of the slice-panic
241
+ /// bug: lowercasing can change byte lengths (e.g. 'İ' grows), so an index
242
+ /// found in the copy is not an index into the original.
243
+ fn rfind_ci(text: &str, needle: &str) -> Option<usize> {
244
+ ci_positions(text, needle).last()
245
+ }
246
+
247
+ fn ci_positions<'a>(text: &'a str, needle: &'a str) -> impl Iterator<Item = usize> + 'a {
248
+ text.char_indices().map(|(i, _)| i).filter(move |&i| {
249
+ text.get(i..i + needle.len())
250
+ .is_some_and(|window| window.eq_ignore_ascii_case(needle))
251
+ })
228
252
  }
229
253
 
230
254
  /// Resolve one spec step into concrete actions for `app`. Out-of-band
231
255
  /// assertions are app-independent — they never touch the UI at all.
256
+ ///
257
+ /// NEVER panics: a panic inside parsing (the 0.2.2/0.2.3 multibyte
258
+ /// slice bugs were this class) is caught and degraded to a clean
259
+ /// `Unresolvable` error naming the step — one bad step must fail one
260
+ /// step, not abort the whole suite. The specific known sites are fixed;
261
+ /// this is the backstop for the variant nobody has written yet.
232
262
  pub fn resolve_step(app: &str, step: &SpecStep) -> Result<Vec<ResolvedAction>, RulesError> {
263
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
264
+ resolve_step_inner(app, step)
265
+ }))
266
+ .unwrap_or_else(|panic| {
267
+ let detail = panic
268
+ .downcast_ref::<String>()
269
+ .map(String::as_str)
270
+ .or_else(|| panic.downcast_ref::<&str>().copied())
271
+ .unwrap_or("unknown panic");
272
+ Err(unresolvable(
273
+ &step.intent(),
274
+ format!("internal parser error ({detail}) — please report this step text"),
275
+ ))
276
+ })
277
+ }
278
+
279
+ fn resolve_step_inner(app: &str, step: &SpecStep) -> Result<Vec<ResolvedAction>, RulesError> {
233
280
  match step {
234
281
  SpecStep::AssertSql { assert_sql } => {
235
282
  return Ok(vec![ResolvedAction::AssertSql {
@@ -410,6 +457,35 @@ mod assertions {
410
457
  .unwrap_or(std::borrow::Cow::Borrowed(trimmed));
411
458
  let trimmed = trimmed.as_ref();
412
459
 
460
+ // `page url is <expected>` / `page url contains <text>`. Checked
461
+ // BEFORE `page shows`, and both auto-wait like every other
462
+ // assertion: an SPA redirect lands asynchronously, so a
463
+ // non-waiting URL assert would be the grammar's only racy form.
464
+ if let Some(rest) = strip_prefix_ci(trimmed, "page url is ") {
465
+ let expected = rest.trim();
466
+ if expected.is_empty() {
467
+ return Err(unresolvable(trimmed, "no expected url"));
468
+ }
469
+ return Ok(vec![ResolvedAction::AssertText {
470
+ target: Target::Surface,
471
+ expected: expected.to_string(),
472
+ matcher: TextMatch::UrlEquals,
473
+ timeout_ms,
474
+ }]);
475
+ }
476
+ if let Some(rest) = strip_prefix_ci(trimmed, "page url contains ") {
477
+ let expected = rest.trim();
478
+ if expected.is_empty() {
479
+ return Err(unresolvable(trimmed, "no expected url"));
480
+ }
481
+ return Ok(vec![ResolvedAction::AssertText {
482
+ target: Target::Surface,
483
+ expected: expected.to_string(),
484
+ matcher: TextMatch::UrlContains,
485
+ timeout_ms,
486
+ }]);
487
+ }
488
+
413
489
  if let Some(rest) = strip_prefix_ci(trimmed, "page shows ") {
414
490
  let (expected, count) = split_count(rest.trim());
415
491
  if expected.is_empty() {
@@ -522,7 +598,8 @@ mod assertions {
522
598
 
523
599
  Err(unresolvable(
524
600
  trimmed,
525
- "expected '[the ]page shows <text>[ N times]', '[the ]page does not show \
601
+ "expected '[the ]page shows <text>[ N times]', '[the ]page url is|contains \
602
+ <url>', '[the ]page does not show \
526
603
  <text>', 'the \"<label>\" field contains <text>', 'the \"<target>\" shows \
527
604
  <text>', 'the \"<target>\" is [not] visible', or 'the \"<target>\" is \
528
605
  enabled|disabled' (see docs/authoring.md for the full grammar)",
@@ -796,8 +873,7 @@ mod web {
796
873
  // `css:` selector) target; `Type <text> into the <id> field` →
797
874
  // `#<id>`; bare `Type <text>` → the focused element.
798
875
  if let Some(rest) = strip_prefix_ci(trimmed, "type ") {
799
- let lower = rest.to_lowercase();
800
- let Some(pos) = lower.rfind(" into the ") else {
876
+ let Some(pos) = rfind_ci(rest, " into the ") else {
801
877
  let value = rest.trim();
802
878
  if value.is_empty() {
803
879
  return Err(unresolvable(trimmed, "nothing to type"));
@@ -842,8 +918,7 @@ mod web {
842
918
  // file-chooser input. The path is stored as written; relative paths
843
919
  // resolve against the working directory at execution time.
844
920
  if let Some(rest) = strip_prefix_ci(trimmed, "upload ") {
845
- let lower = rest.to_lowercase();
846
- if let Some(pos) = lower.rfind(" into the ") {
921
+ if let Some(pos) = rfind_ci(rest, " into the ") {
847
922
  let path = rest[..pos].trim().trim_matches('"');
848
923
  let field = rest[pos + " into the ".len()..].trim();
849
924
  if path.is_empty() {
@@ -882,11 +957,9 @@ mod web {
882
957
  // option its own way (the web driver goes through the select's
883
958
  // native value setter and fires input+change).
884
959
  if let Some(rest) = strip_prefix_ci(trimmed, "select ") {
885
- let lower = rest.to_lowercase();
886
- let split = lower
887
- .rfind(" from the ")
960
+ let split = rfind_ci(rest, " from the ")
888
961
  .map(|p| (p, " from the ".len()))
889
- .or_else(|| lower.rfind(" in the ").map(|p| (p, " in the ".len())));
962
+ .or_else(|| rfind_ci(rest, " in the ").map(|p| (p, " in the ".len())));
890
963
  if let Some((pos, sep_len)) = split {
891
964
  let value = rest[..pos].trim();
892
965
  let (nth, field) = split_ordinal(rest[pos + sep_len..].trim());
@@ -1930,3 +2003,105 @@ mod tests {
1930
2003
  );
1931
2004
  }
1932
2005
  }
2006
+
2007
+ #[cfg(test)]
2008
+ mod multibyte_tests {
2009
+ use super::*;
2010
+
2011
+ /// The 0.2.2 and 0.2.3 field escapes were both char-boundary slice
2012
+ /// panics on multibyte step text. Sweep every grammar form with a
2013
+ /// zoo of multibyte content — with and without `=`, which is what
2014
+ /// pushed the 0.2.3 variant past the suffix-strip boundary — and
2015
+ /// require that EVERY step either resolves or fails cleanly. A panic
2016
+ /// here is the bug, regardless of which slice site regressed.
2017
+ #[test]
2018
+ fn multibyte_step_text_never_panics_across_the_grammar() {
2019
+ const ZOO: &[&str] = &["×", "÷", "é", "café", "測試", "🚀", "İstanbul"];
2020
+ let mut cases: Vec<(String, String)> = Vec::new();
2021
+ for chunk in ZOO {
2022
+ for tail in ["", "=20", "=x", " = 5"] {
2023
+ let payload = format!("4 {chunk} 5{tail}");
2024
+ for step in [
2025
+ format!("page shows {payload}"),
2026
+ format!("page shows {payload} 2 times"),
2027
+ format!("page does not show {payload}"),
2028
+ format!("Wait until page shows {payload} within 5s"),
2029
+ format!("Type {payload} into the \"Name\" field"),
2030
+ format!("Type {payload}"),
2031
+ format!("Replace the \"Name\" field with {payload}"),
2032
+ format!("Select {payload} from the \"Kind\" field"),
2033
+ format!("Upload {payload} into the \"File\" field"),
2034
+ format!("Click \"{payload}\""),
2035
+ format!("Right-click \"{payload}\""),
2036
+ format!("Press the \"{payload}\" button"),
2037
+ format!("the \"{payload}\" shows {payload}"),
2038
+ format!("the \"Name\" field contains {payload}"),
2039
+ payload.clone(),
2040
+ ] {
2041
+ cases.push((step, payload.clone()));
2042
+ }
2043
+ }
2044
+ }
2045
+ for (step_text, _payload) in &cases {
2046
+ for kind in [
2047
+ SpecStep::Plain(step_text.clone()),
2048
+ SpecStep::Assert {
2049
+ assert: step_text.clone(),
2050
+ },
2051
+ ] {
2052
+ // Ok or Err are both fine — only a panic is a failure.
2053
+ let _ = resolve_step("web", &kind);
2054
+ let _ = resolve_step("vision", &kind);
2055
+ }
2056
+ }
2057
+ }
2058
+
2059
+ /// The exact 0.2.3 field repro, pinned: multibyte char followed
2060
+ /// later by `=` must resolve as a surface-text assertion.
2061
+ #[test]
2062
+ fn multibyte_with_equals_resolves_as_page_shows() {
2063
+ for text in ["page shows 4 × 5=20", "page shows a × b=c"] {
2064
+ let step = SpecStep::Assert {
2065
+ assert: text.to_string(),
2066
+ };
2067
+ let actions = resolve_step("web", &step).expect("resolves cleanly");
2068
+ match &actions[0] {
2069
+ ResolvedAction::AssertText {
2070
+ expected, matcher, ..
2071
+ } => {
2072
+ assert_eq!(
2073
+ expected,
2074
+ text.strip_prefix("page shows ").expect("test constant")
2075
+ );
2076
+ assert_eq!(*matcher, TextMatch::Contains);
2077
+ }
2078
+ other => panic!("expected surface AssertText, got {other:?}"),
2079
+ }
2080
+ }
2081
+ // The counting form survives multibyte too (its ` times` suffix
2082
+ // strip was the 0.2.3 panic site).
2083
+ let step = SpecStep::Assert {
2084
+ assert: "page shows 4 × 5=20 2 times".into(),
2085
+ };
2086
+ let actions = resolve_step("web", &step).expect("resolves cleanly");
2087
+ assert!(matches!(
2088
+ &actions[0],
2089
+ ResolvedAction::AssertText {
2090
+ matcher: TextMatch::CountEquals(2),
2091
+ ..
2092
+ }
2093
+ ));
2094
+ }
2095
+
2096
+ /// The backstop: even if a future slice bug panics inside parsing,
2097
+ /// the caller gets a clean error, not a dead suite.
2098
+ #[test]
2099
+ fn parser_panics_degrade_to_clean_errors() {
2100
+ // No known panicking input exists anymore (that is the point of
2101
+ // the fixes), so prove the mechanism directly.
2102
+ let caught = std::panic::catch_unwind(|| {
2103
+ resolve_step("web", &SpecStep::Plain("page shows anything".into()))
2104
+ });
2105
+ assert!(caught.is_ok(), "resolve_step must never unwind");
2106
+ }
2107
+ }