flowproof 0.2.3__tar.gz → 0.2.4__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.4}/Cargo.lock +7 -7
  2. {flowproof-0.2.3 → flowproof-0.2.4}/Cargo.toml +1 -1
  3. {flowproof-0.2.3 → flowproof-0.2.4}/PKG-INFO +1 -1
  4. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-adapters/src/web.rs +18 -8
  5. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-agent/src/recorder.rs +20 -3
  6. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-agent/src/rules.rs +163 -24
  7. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/src/lib.rs +140 -19
  8. flowproof-0.2.4/crates/flowproof-cli/tests/suite_flow_isolation.rs +93 -0
  9. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/suite_missing_trace.rs +10 -2
  10. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/lib.rs +20 -0
  11. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/mock.rs +22 -0
  12. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-replay/src/lib.rs +103 -20
  13. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-replay/src/report.rs +79 -7
  14. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-replay/tests/replay_calc.rs +179 -0
  15. {flowproof-0.2.3 → flowproof-0.2.4}/flowproof/__init__.py +1 -1
  16. {flowproof-0.2.3 → flowproof-0.2.4}/pyproject.toml +1 -1
  17. {flowproof-0.2.3 → flowproof-0.2.4}/README.md +0 -0
  18. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-adapters/Cargo.toml +0 -0
  19. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-adapters/src/lib.rs +0 -0
  20. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-adapters/src/sap_com.rs +0 -0
  21. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-adapters/src/vision.rs +0 -0
  22. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-agent/Cargo.toml +0 -0
  23. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-agent/src/author.rs +0 -0
  24. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-agent/src/clarify.rs +0 -0
  25. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-agent/src/heal.rs +0 -0
  26. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-agent/src/lib.rs +0 -0
  27. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-agent/src/llm.rs +0 -0
  28. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-agent/src/spec.rs +0 -0
  29. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/Cargo.toml +0 -0
  30. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/src/main.rs +0 -0
  31. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  32. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  33. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
  34. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  35. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  36. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  37. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  38. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  39. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  40. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  41. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  42. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  43. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  44. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-cli/tests/web_e2e.rs +0 -0
  45. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/Cargo.toml +0 -0
  46. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/app.rs +0 -0
  47. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/backend.rs +0 -0
  48. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/gdi.rs +0 -0
  49. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/oob.rs +0 -0
  50. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/recording.rs +0 -0
  51. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/redact.rs +0 -0
  52. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/visual.rs +0 -0
  53. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-driver/src/window.rs +0 -0
  54. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-python/Cargo.toml +0 -0
  55. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-python/src/lib.rs +0 -0
  56. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-replay/Cargo.toml +0 -0
  57. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-trace/Cargo.toml +0 -0
  58. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  59. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-trace/src/format.rs +0 -0
  60. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-trace/src/lib.rs +0 -0
  61. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-trace/src/secret.rs +0 -0
  62. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  63. {flowproof-0.2.3 → flowproof-0.2.4}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  64. {flowproof-0.2.3 → flowproof-0.2.4}/flowproof/cli.py +0 -0
  65. {flowproof-0.2.3 → flowproof-0.2.4}/flowproof/flow.py +0 -0
  66. {flowproof-0.2.3 → flowproof-0.2.4}/flowproof/mcp_server.py +0 -0
  67. {flowproof-0.2.3 → flowproof-0.2.4}/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.4"
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.4"
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.4"
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.4"
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.4"
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.4"
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.4"
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.4"
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.4
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -19,8 +19,18 @@ 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
 
26
36
  /// Launch a fresh headless Chromium (`CHROME` env var overrides the
@@ -210,7 +220,7 @@ impl WebAppDriver {
210
220
  fn tab(&self) -> Result<&Arc<Tab>, DriverError> {
211
221
  self.tab
212
222
  .as_ref()
213
- .ok_or_else(|| DriverError::Uia("web: no page open: call launch first".into()))
223
+ .ok_or_else(|| DriverError::Browser("no page open: call launch first".into()))
214
224
  }
215
225
 
216
226
  fn locator_of(selector: &UiaSelector) -> Option<WebLocator> {
@@ -234,8 +244,8 @@ impl WebAppDriver {
234
244
 
235
245
  fn locator(selector: &UiaSelector) -> Result<WebLocator, DriverError> {
236
246
  Self::locator_of(selector).ok_or_else(|| {
237
- DriverError::Uia(format!(
238
- "web: selector [{selector}] has no css, automation_id, or text"
247
+ DriverError::Browser(format!(
248
+ "selector [{selector}] has no css, automation_id, or text"
239
249
  ))
240
250
  })
241
251
  }
@@ -279,7 +289,7 @@ impl WebAppDriver {
279
289
  return Ok(element);
280
290
  }
281
291
  if std::time::Instant::now() >= deadline {
282
- return Err(DriverError::Uia(format!("web: no element for {locator}")));
292
+ return Err(DriverError::Browser(format!("no element for {locator}")));
283
293
  }
284
294
  std::thread::sleep(Duration::from_millis(100));
285
295
  }
@@ -473,8 +483,8 @@ impl AppDriver for WebAppDriver {
473
483
  // isolated), paying its cold start instead of sharing.
474
484
  if let Some(config) = &staged_browser {
475
485
  if !config.args.is_empty() {
476
- self.browser =
477
- launch_browser(&config.args).map_err(|e| DriverError::Uia(e.to_string()))?;
486
+ self.browser = launch_browser(&config.args)
487
+ .map_err(|e| DriverError::Browser(e.to_string()))?;
478
488
  self.context_id = None;
479
489
  }
480
490
  }
@@ -1035,7 +1045,7 @@ impl AppDriver for WebAppDriver {
1035
1045
  let json = value
1036
1046
  .value
1037
1047
  .and_then(|v| v.as_str().map(str::to_string))
1038
- .ok_or_else(|| DriverError::Uia("web: scene script returned no value".into()))?;
1048
+ .ok_or_else(|| DriverError::Browser("scene script returned no value".into()))?;
1039
1049
  Ok(Some(json))
1040
1050
  }
1041
1051
 
@@ -896,11 +896,28 @@ fn poll_oob(
896
896
  }
897
897
 
898
898
  fn assert_holds(actual: &str, expected: &str, matcher: TextMatch) -> bool {
899
+ // Case-insensitive FALLBACK, mirroring element anchors: an exact
900
+ // match always wins; when it misses, lowercased comparison decides
901
+ // ("page shows Close Account" against a page reading "Close
902
+ // account").
903
+ //
904
+ // Widening-only, and it must stay byte-identical to replay's
905
+ // `text_matches`: if record and replay disagree about a single
906
+ // assertion, record mints a trace that cannot replay - the exact
907
+ // failure mode the round-3 field run was built to catch. The negative
908
+ // form therefore does NOT take the fallback (widening it would fail
909
+ // traces that used to pass), and a nonzero case-sensitive count is
910
+ // the count.
911
+ let (actual_ci, expected_ci) = (actual.to_lowercase(), expected.to_lowercase());
899
912
  match matcher {
900
- TextMatch::Contains => actual.contains(expected),
913
+ TextMatch::Contains => actual.contains(expected) || actual_ci.contains(&expected_ci),
901
914
  TextMatch::NotContains => !actual.contains(expected),
902
- TextMatch::CountEquals(n) => actual.matches(expected).count() as u64 == n,
903
- TextMatch::Equals => actual == expected,
915
+ TextMatch::CountEquals(n) => {
916
+ let sensitive = actual.matches(expected).count() as u64;
917
+ sensitive == n
918
+ || (sensitive == 0 && actual_ci.matches(&expected_ci).count() as u64 == n)
919
+ }
920
+ TextMatch::Equals => actual == expected || actual_ci == expected_ci,
904
921
  TextMatch::NumericEquals => matches!(
905
922
  (flowproof_driver::numeric_value(actual), expected.parse::<f64>()),
906
923
  (Some(a), Ok(e)) if a == e
@@ -175,11 +175,13 @@ pub const WAIT_STEP_TIMEOUT_MS: u64 = 60_000;
175
175
  /// Parse a trailing `within <N>s` / `within <N> seconds` qualifier off a
176
176
  /// step, returning (rest, timeout override).
177
177
  fn split_within(text: &str) -> (&str, Option<u64>) {
178
- let lower = text.to_lowercase();
179
- let Some(pos) = lower.rfind(" within ") else {
178
+ // rfind_ci returns indices valid in `text` itself — never positions
179
+ // from a lowercased copy, whose byte offsets can differ.
180
+ let Some(pos) = rfind_ci(text, " within ") else {
180
181
  return (text, None);
181
182
  };
182
- let qualifier = lower[pos + " within ".len()..].trim();
183
+ let qualifier = text[pos + " within ".len()..].trim().to_lowercase();
184
+ let qualifier = qualifier.as_str();
183
185
  let digits = qualifier
184
186
  .strip_suffix(" seconds")
185
187
  .or_else(|| qualifier.strip_suffix(" second"))
@@ -207,29 +209,68 @@ fn unresolvable(step: &str, reason: impl Into<String>) -> RulesError {
207
209
  }
208
210
 
209
211
  /// Case-insensitively strip an ASCII `prefix`, returning the rest of the
210
- /// ORIGINAL string (case preserved).
212
+ /// ORIGINAL string (case preserved). Boundary-safe: `get` returns None
213
+ /// when the cut would split a multibyte char, and an ASCII prefix cannot
214
+ /// match across one — so "no match" is the correct answer, never a panic.
211
215
  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
- }
216
+ let head = text.get(..prefix.len())?;
217
+ head.eq_ignore_ascii_case(prefix)
218
+ .then(|| &text[prefix.len()..])
217
219
  }
218
220
 
219
221
  /// Case-insensitively strip an ASCII `suffix`, returning the front of the
220
- /// ORIGINAL string (case preserved).
222
+ /// ORIGINAL string (case preserved). Boundary-safe like `strip_prefix_ci`:
223
+ /// `text.len() - suffix.len()` is a byte offset that can land inside a
224
+ /// multibyte char (`4 × 5=20` with suffix ` times`), which must mean "no
225
+ /// match", not a slice panic — the 0.2.3 escape.
221
226
  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
- }
227
+ let split = text.len().checked_sub(suffix.len())?;
228
+ let tail = text.get(split..)?;
229
+ tail.eq_ignore_ascii_case(suffix).then(|| &text[..split])
230
+ }
231
+
232
+ /// Byte index of the last case-insensitive occurrence of ASCII `needle`
233
+ /// in `text`, ALWAYS valid for slicing `text` itself. The
234
+ /// `text.to_lowercase().find(…)` idiom is the sibling of the slice-panic
235
+ /// bug: lowercasing can change byte lengths (e.g. 'İ' grows), so an index
236
+ /// found in the copy is not an index into the original.
237
+ fn rfind_ci(text: &str, needle: &str) -> Option<usize> {
238
+ ci_positions(text, needle).last()
239
+ }
240
+
241
+ fn ci_positions<'a>(text: &'a str, needle: &'a str) -> impl Iterator<Item = usize> + 'a {
242
+ text.char_indices().map(|(i, _)| i).filter(move |&i| {
243
+ text.get(i..i + needle.len())
244
+ .is_some_and(|window| window.eq_ignore_ascii_case(needle))
245
+ })
228
246
  }
229
247
 
230
248
  /// Resolve one spec step into concrete actions for `app`. Out-of-band
231
249
  /// assertions are app-independent — they never touch the UI at all.
250
+ ///
251
+ /// NEVER panics: a panic inside parsing (the 0.2.2/0.2.3 multibyte
252
+ /// slice bugs were this class) is caught and degraded to a clean
253
+ /// `Unresolvable` error naming the step — one bad step must fail one
254
+ /// step, not abort the whole suite. The specific known sites are fixed;
255
+ /// this is the backstop for the variant nobody has written yet.
232
256
  pub fn resolve_step(app: &str, step: &SpecStep) -> Result<Vec<ResolvedAction>, RulesError> {
257
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
258
+ resolve_step_inner(app, step)
259
+ }))
260
+ .unwrap_or_else(|panic| {
261
+ let detail = panic
262
+ .downcast_ref::<String>()
263
+ .map(String::as_str)
264
+ .or_else(|| panic.downcast_ref::<&str>().copied())
265
+ .unwrap_or("unknown panic");
266
+ Err(unresolvable(
267
+ &step.intent(),
268
+ format!("internal parser error ({detail}) — please report this step text"),
269
+ ))
270
+ })
271
+ }
272
+
273
+ fn resolve_step_inner(app: &str, step: &SpecStep) -> Result<Vec<ResolvedAction>, RulesError> {
233
274
  match step {
234
275
  SpecStep::AssertSql { assert_sql } => {
235
276
  return Ok(vec![ResolvedAction::AssertSql {
@@ -796,8 +837,7 @@ mod web {
796
837
  // `css:` selector) target; `Type <text> into the <id> field` →
797
838
  // `#<id>`; bare `Type <text>` → the focused element.
798
839
  if let Some(rest) = strip_prefix_ci(trimmed, "type ") {
799
- let lower = rest.to_lowercase();
800
- let Some(pos) = lower.rfind(" into the ") else {
840
+ let Some(pos) = rfind_ci(rest, " into the ") else {
801
841
  let value = rest.trim();
802
842
  if value.is_empty() {
803
843
  return Err(unresolvable(trimmed, "nothing to type"));
@@ -842,8 +882,7 @@ mod web {
842
882
  // file-chooser input. The path is stored as written; relative paths
843
883
  // resolve against the working directory at execution time.
844
884
  if let Some(rest) = strip_prefix_ci(trimmed, "upload ") {
845
- let lower = rest.to_lowercase();
846
- if let Some(pos) = lower.rfind(" into the ") {
885
+ if let Some(pos) = rfind_ci(rest, " into the ") {
847
886
  let path = rest[..pos].trim().trim_matches('"');
848
887
  let field = rest[pos + " into the ".len()..].trim();
849
888
  if path.is_empty() {
@@ -882,11 +921,9 @@ mod web {
882
921
  // option its own way (the web driver goes through the select's
883
922
  // native value setter and fires input+change).
884
923
  if let Some(rest) = strip_prefix_ci(trimmed, "select ") {
885
- let lower = rest.to_lowercase();
886
- let split = lower
887
- .rfind(" from the ")
924
+ let split = rfind_ci(rest, " from the ")
888
925
  .map(|p| (p, " from the ".len()))
889
- .or_else(|| lower.rfind(" in the ").map(|p| (p, " in the ".len())));
926
+ .or_else(|| rfind_ci(rest, " in the ").map(|p| (p, " in the ".len())));
890
927
  if let Some((pos, sep_len)) = split {
891
928
  let value = rest[..pos].trim();
892
929
  let (nth, field) = split_ordinal(rest[pos + sep_len..].trim());
@@ -1930,3 +1967,105 @@ mod tests {
1930
1967
  );
1931
1968
  }
1932
1969
  }
1970
+
1971
+ #[cfg(test)]
1972
+ mod multibyte_tests {
1973
+ use super::*;
1974
+
1975
+ /// The 0.2.2 and 0.2.3 field escapes were both char-boundary slice
1976
+ /// panics on multibyte step text. Sweep every grammar form with a
1977
+ /// zoo of multibyte content — with and without `=`, which is what
1978
+ /// pushed the 0.2.3 variant past the suffix-strip boundary — and
1979
+ /// require that EVERY step either resolves or fails cleanly. A panic
1980
+ /// here is the bug, regardless of which slice site regressed.
1981
+ #[test]
1982
+ fn multibyte_step_text_never_panics_across_the_grammar() {
1983
+ const ZOO: &[&str] = &["×", "÷", "é", "café", "測試", "🚀", "İstanbul"];
1984
+ let mut cases: Vec<(String, String)> = Vec::new();
1985
+ for chunk in ZOO {
1986
+ for tail in ["", "=20", "=x", " = 5"] {
1987
+ let payload = format!("4 {chunk} 5{tail}");
1988
+ for step in [
1989
+ format!("page shows {payload}"),
1990
+ format!("page shows {payload} 2 times"),
1991
+ format!("page does not show {payload}"),
1992
+ format!("Wait until page shows {payload} within 5s"),
1993
+ format!("Type {payload} into the \"Name\" field"),
1994
+ format!("Type {payload}"),
1995
+ format!("Replace the \"Name\" field with {payload}"),
1996
+ format!("Select {payload} from the \"Kind\" field"),
1997
+ format!("Upload {payload} into the \"File\" field"),
1998
+ format!("Click \"{payload}\""),
1999
+ format!("Right-click \"{payload}\""),
2000
+ format!("Press the \"{payload}\" button"),
2001
+ format!("the \"{payload}\" shows {payload}"),
2002
+ format!("the \"Name\" field contains {payload}"),
2003
+ payload.clone(),
2004
+ ] {
2005
+ cases.push((step, payload.clone()));
2006
+ }
2007
+ }
2008
+ }
2009
+ for (step_text, _payload) in &cases {
2010
+ for kind in [
2011
+ SpecStep::Plain(step_text.clone()),
2012
+ SpecStep::Assert {
2013
+ assert: step_text.clone(),
2014
+ },
2015
+ ] {
2016
+ // Ok or Err are both fine — only a panic is a failure.
2017
+ let _ = resolve_step("web", &kind);
2018
+ let _ = resolve_step("vision", &kind);
2019
+ }
2020
+ }
2021
+ }
2022
+
2023
+ /// The exact 0.2.3 field repro, pinned: multibyte char followed
2024
+ /// later by `=` must resolve as a surface-text assertion.
2025
+ #[test]
2026
+ fn multibyte_with_equals_resolves_as_page_shows() {
2027
+ for text in ["page shows 4 × 5=20", "page shows a × b=c"] {
2028
+ let step = SpecStep::Assert {
2029
+ assert: text.to_string(),
2030
+ };
2031
+ let actions = resolve_step("web", &step).expect("resolves cleanly");
2032
+ match &actions[0] {
2033
+ ResolvedAction::AssertText {
2034
+ expected, matcher, ..
2035
+ } => {
2036
+ assert_eq!(
2037
+ expected,
2038
+ text.strip_prefix("page shows ").expect("test constant")
2039
+ );
2040
+ assert_eq!(*matcher, TextMatch::Contains);
2041
+ }
2042
+ other => panic!("expected surface AssertText, got {other:?}"),
2043
+ }
2044
+ }
2045
+ // The counting form survives multibyte too (its ` times` suffix
2046
+ // strip was the 0.2.3 panic site).
2047
+ let step = SpecStep::Assert {
2048
+ assert: "page shows 4 × 5=20 2 times".into(),
2049
+ };
2050
+ let actions = resolve_step("web", &step).expect("resolves cleanly");
2051
+ assert!(matches!(
2052
+ &actions[0],
2053
+ ResolvedAction::AssertText {
2054
+ matcher: TextMatch::CountEquals(2),
2055
+ ..
2056
+ }
2057
+ ));
2058
+ }
2059
+
2060
+ /// The backstop: even if a future slice bug panics inside parsing,
2061
+ /// the caller gets a clean error, not a dead suite.
2062
+ #[test]
2063
+ fn parser_panics_degrade_to_clean_errors() {
2064
+ // No known panicking input exists anymore (that is the point of
2065
+ // the fixes), so prove the mechanism directly.
2066
+ let caught = std::panic::catch_unwind(|| {
2067
+ resolve_step("web", &SpecStep::Plain("page shows anything".into()))
2068
+ });
2069
+ assert!(caught.is_ok(), "resolve_step must never unwind");
2070
+ }
2071
+ }
@@ -496,6 +496,30 @@ fn record_one(
496
496
  /// Run every recorded flow under `dir` as one suite: per-flow bundles as
497
497
  /// usual, plus a merged `junit.xml` for CI, and a non-zero exit if ANY flow
498
498
  /// fails. A failing flow does not stop the suite.
499
+ /// Record one flow as ERRORED and keep the suite going. A driver fault, an
500
+ /// unreadable trace or a failing seed hook is one flow's problem: before
501
+ /// this existed, the first such fault aborted the whole run and no merged
502
+ /// junit was written at all, so CI saw nothing (field report, round 3).
503
+ fn errored_flow(
504
+ spec_path: &Path,
505
+ name: &str,
506
+ message: String,
507
+ json: bool,
508
+ flows: &mut Vec<serde_json::Value>,
509
+ reports: &mut Vec<flowproof_replay::RunReport>,
510
+ ) {
511
+ let report = flowproof_replay::RunReport::errored(name, &message);
512
+ if !json {
513
+ println!("[ERROR] {name} ({message})");
514
+ }
515
+ flows.push(serde_json::json!({
516
+ "spec": spec_path,
517
+ "report": report,
518
+ "report_path": null,
519
+ }));
520
+ reports.push(report);
521
+ }
522
+
499
523
  pub fn run_suite(dir: &Path, json: bool, retries: u8, missing: MissingTrace) -> Result<u8, String> {
500
524
  let mut specs = Vec::new();
501
525
  discover_specs(dir, &mut specs)?;
@@ -520,7 +544,22 @@ pub fn run_suite(dir: &Path, json: bool, retries: u8, missing: MissingTrace) ->
520
544
  // missing-trace error): a deliberately gated flow with no trace
521
545
  // is a skip, not a failure. Loading here also surfaces spec parse
522
546
  // errors for every suite member.
523
- let gated_spec = FlowSpec::load(spec_path).map_err(|e| e.to_string())?;
547
+ let gated_spec = match FlowSpec::load(spec_path).map_err(|e| e.to_string()) {
548
+ Ok(spec) => spec,
549
+ // A spec that will not parse is one broken flow, not a broken
550
+ // suite: record it and keep going.
551
+ Err(e) => {
552
+ errored_flow(
553
+ spec_path,
554
+ &spec_path.display().to_string(),
555
+ e,
556
+ json,
557
+ &mut flows,
558
+ &mut reports,
559
+ );
560
+ continue;
561
+ }
562
+ };
524
563
  if let Some(reason) = gated_spec.skip_reason() {
525
564
  let report = flowproof_replay::RunReport::skipped(&gated_spec.name, &reason);
526
565
  if !json {
@@ -538,17 +577,35 @@ pub fn run_suite(dir: &Path, json: bool, retries: u8, missing: MissingTrace) ->
538
577
  if !trace_path.exists() {
539
578
  match missing {
540
579
  MissingTrace::Error => {
541
- return Err(format!(
542
- "trace {} not found — run `flowproof record {}` first",
543
- trace_path.display(),
544
- spec_path.display()
545
- ));
580
+ errored_flow(
581
+ spec_path,
582
+ &gated_spec.name,
583
+ format!(
584
+ "trace {} not found — run `flowproof record {}` first",
585
+ trace_path.display(),
586
+ spec_path.display()
587
+ ),
588
+ json,
589
+ &mut flows,
590
+ &mut reports,
591
+ );
592
+ continue;
546
593
  }
547
594
  MissingTrace::Record => {
548
595
  if !json {
549
596
  println!("[RECORD] {} (no trace yet)", spec_path.display());
550
597
  }
551
- record_one(spec_path, &trace_path, manifest.browser.as_ref())?;
598
+ if let Err(e) = record_one(spec_path, &trace_path, manifest.browser.as_ref()) {
599
+ errored_flow(
600
+ spec_path,
601
+ &gated_spec.name,
602
+ e,
603
+ json,
604
+ &mut flows,
605
+ &mut reports,
606
+ );
607
+ continue;
608
+ }
552
609
  // Fall through to the normal replay below.
553
610
  }
554
611
  MissingTrace::Skip => {
@@ -575,17 +632,61 @@ pub fn run_suite(dir: &Path, json: bool, retries: u8, missing: MissingTrace) ->
575
632
  }
576
633
  // Seed before the flow; a failing hook fails the flow, not the run.
577
634
  if let Some(cmd) = &manifest.before_each {
578
- run_hook(cmd, spec_path, "before_each")?;
579
- }
580
- let (header, _) = flowproof_replay::load_trace(&trace_path).map_err(|e| e.to_string())?;
581
- // A fresh driver per flow: full isolation, like Playwright contexts.
582
- let (report, run_dir, attempts) =
583
- replay_with_retries(&trace_path, &header.app.name, retries, !json)?;
584
- // Cleanup always runs, pass or fail.
585
- if let Some(cmd) = &manifest.after_each {
586
- run_hook(cmd, spec_path, "after_each")?;
635
+ if let Err(e) = run_hook(cmd, spec_path, "before_each") {
636
+ errored_flow(
637
+ spec_path,
638
+ &gated_spec.name,
639
+ e,
640
+ json,
641
+ &mut flows,
642
+ &mut reports,
643
+ );
644
+ continue;
645
+ }
587
646
  }
588
- let result_path = report.write_into(&run_dir).map_err(|e| e.to_string())?;
647
+ let replayed = flowproof_replay::load_trace(&trace_path)
648
+ .map_err(|e| e.to_string())
649
+ // A fresh driver per flow: full isolation, like Playwright
650
+ // contexts. A driver fault here ends THIS flow only.
651
+ .and_then(|(header, _)| {
652
+ replay_with_retries(&trace_path, &header.app.name, retries, !json)
653
+ });
654
+ // Cleanup always runs, pass, fail or error.
655
+ let cleanup = match &manifest.after_each {
656
+ Some(cmd) => run_hook(cmd, spec_path, "after_each"),
657
+ None => Ok(()),
658
+ };
659
+ // Replay first, cleanup second, and only then decide the outcome:
660
+ // whichever failed, the cleanup has already run.
661
+ let (report, run_dir, attempts) = match replayed.and_then(|triple| cleanup.map(|()| triple))
662
+ {
663
+ Ok(triple) => triple,
664
+ Err(e) => {
665
+ errored_flow(
666
+ spec_path,
667
+ &gated_spec.name,
668
+ e,
669
+ json,
670
+ &mut flows,
671
+ &mut reports,
672
+ );
673
+ continue;
674
+ }
675
+ };
676
+ let result_path = match report.write_into(&run_dir).map_err(|e| e.to_string()) {
677
+ Ok(path) => path,
678
+ Err(e) => {
679
+ errored_flow(
680
+ spec_path,
681
+ &gated_spec.name,
682
+ e,
683
+ json,
684
+ &mut flows,
685
+ &mut reports,
686
+ );
687
+ continue;
688
+ }
689
+ };
589
690
  if !json {
590
691
  println!(
591
692
  "[{}] {} ({} ms){}{}",
@@ -628,6 +729,7 @@ pub fn run_suite(dir: &Path, json: bool, retries: u8, missing: MissingTrace) ->
628
729
  .map_err(|e| e.to_string())?;
629
730
 
630
731
  let skipped = reports.iter().filter(|r| r.trace_id == "skipped").count();
732
+ let errored = reports.iter().filter(|r| r.trace_id == "errored").count();
631
733
  let passed = reports.iter().filter(|r| r.passed).count() - skipped;
632
734
  let ran = reports.len() - skipped;
633
735
  let all_passed = reports.iter().all(|r| r.passed);
@@ -636,6 +738,7 @@ pub fn run_suite(dir: &Path, json: bool, retries: u8, missing: MissingTrace) ->
636
738
  "flows": flows,
637
739
  "passed": all_passed,
638
740
  "skipped": skipped,
741
+ "errored": errored,
639
742
  "junit_path": junit_path,
640
743
  });
641
744
  println!(
@@ -644,20 +747,31 @@ pub fn run_suite(dir: &Path, json: bool, retries: u8, missing: MissingTrace) ->
644
747
  );
645
748
  } else {
646
749
  println!(
647
- "{}: {passed}/{ran} flows passed{} -> {}",
750
+ "{}: {passed}/{ran} flows passed{}{} -> {}",
648
751
  if all_passed { "PASS" } else { "FAIL" },
649
752
  if skipped > 0 {
650
753
  format!(", {skipped} skipped")
651
754
  } else {
652
755
  String::new()
653
756
  },
757
+ if errored > 0 {
758
+ format!(", {errored} errored")
759
+ } else {
760
+ String::new()
761
+ },
654
762
  junit_path.display()
655
763
  );
656
764
  if reports.iter().any(|r| r.degraded) {
657
765
  println!("DEGRADED: fallback selectors were needed in some flows — heal them");
658
766
  }
659
767
  }
660
- Ok(if all_passed { EXIT_PASS } else { EXIT_FAIL })
768
+ Ok(if errored > 0 {
769
+ EXIT_ERROR
770
+ } else if all_passed {
771
+ EXIT_PASS
772
+ } else {
773
+ EXIT_FAIL
774
+ })
661
775
  }
662
776
 
663
777
  fn cmd_run(
@@ -731,6 +845,13 @@ fn cmd_run(
731
845
  .unwrap_or_default(),
732
846
  ),
733
847
  StepStatus::Skipped => ("SKIP", String::new()),
848
+ StepStatus::Errored => (
849
+ "ERROR",
850
+ step.detail
851
+ .as_deref()
852
+ .map(|d| format!(" — {d}"))
853
+ .unwrap_or_default(),
854
+ ),
734
855
  };
735
856
  if step.degraded {
736
857
  let tier = step.selector_tier.as_deref().unwrap_or("fallback");