flowproof 0.2.4__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.4 → flowproof-0.2.5}/Cargo.lock +7 -7
  2. {flowproof-0.2.4 → flowproof-0.2.5}/Cargo.toml +1 -1
  3. {flowproof-0.2.4 → flowproof-0.2.5}/PKG-INFO +1 -1
  4. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/src/web.rs +58 -0
  5. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/recorder.rs +18 -7
  6. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/rules.rs +37 -1
  7. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/src/lib.rs +33 -5
  8. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/suite_env_from.rs +96 -0
  9. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/web_e2e.rs +173 -0
  10. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/app.rs +178 -0
  11. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/lib.rs +3 -3
  12. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/mock.rs +13 -0
  13. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-replay/src/lib.rs +68 -0
  14. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-replay/tests/replay_calc.rs +92 -0
  15. {flowproof-0.2.4 → flowproof-0.2.5}/flowproof/__init__.py +1 -1
  16. {flowproof-0.2.4 → flowproof-0.2.5}/pyproject.toml +1 -1
  17. {flowproof-0.2.4 → flowproof-0.2.5}/README.md +0 -0
  18. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/Cargo.toml +0 -0
  19. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/src/lib.rs +0 -0
  20. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/src/sap_com.rs +0 -0
  21. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/src/vision.rs +0 -0
  22. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/Cargo.toml +0 -0
  23. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/author.rs +0 -0
  24. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/clarify.rs +0 -0
  25. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/heal.rs +0 -0
  26. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/lib.rs +0 -0
  27. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/llm.rs +0 -0
  28. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/spec.rs +0 -0
  29. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/Cargo.toml +0 -0
  30. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/src/main.rs +0 -0
  31. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  32. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  33. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
  34. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  35. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  36. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  37. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  38. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  39. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  40. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  41. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  42. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  43. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  44. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  45. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/Cargo.toml +0 -0
  46. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/backend.rs +0 -0
  47. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/gdi.rs +0 -0
  48. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/oob.rs +0 -0
  49. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/recording.rs +0 -0
  50. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/redact.rs +0 -0
  51. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/visual.rs +0 -0
  52. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/window.rs +0 -0
  53. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-python/Cargo.toml +0 -0
  54. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-python/src/lib.rs +0 -0
  55. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-replay/Cargo.toml +0 -0
  56. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-replay/src/report.rs +0 -0
  57. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/Cargo.toml +0 -0
  58. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  59. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/src/format.rs +0 -0
  60. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/src/lib.rs +0 -0
  61. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/src/secret.rs +0 -0
  62. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  63. {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  64. {flowproof-0.2.4 → flowproof-0.2.5}/flowproof/cli.py +0 -0
  65. {flowproof-0.2.4 → flowproof-0.2.5}/flowproof/flow.py +0 -0
  66. {flowproof-0.2.4 → flowproof-0.2.5}/flowproof/mcp_server.py +0 -0
  67. {flowproof-0.2.4 → 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.4"
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.4"
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.4"
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.4"
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.4"
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.4"
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.4"
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.4"
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.4
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
@@ -33,12 +33,50 @@ fn web_err(context: &str, err: impl std::fmt::Display) -> DriverError {
33
33
  }
34
34
  }
35
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
+
36
73
  /// Launch a fresh headless Chromium (`CHROME` env var overrides the
37
74
  /// binary), optionally with extra command-line flags.
38
75
  fn launch_browser(extra_args: &[String]) -> Result<Browser, AdapterError> {
39
76
  let os_args: Vec<std::ffi::OsString> = extra_args.iter().map(Into::into).collect();
40
77
  let mut options = LaunchOptions::default_builder();
41
78
  options.headless(true).sandbox(false);
79
+ options.idle_browser_timeout(BROWSER_IDLE_TIMEOUT);
42
80
  options.args(os_args.iter().map(AsRef::as_ref).collect());
43
81
  if let Ok(path) = std::env::var("CHROME") {
44
82
  options.path(Some(path.into()));
@@ -677,6 +715,17 @@ impl AppDriver for WebAppDriver {
677
715
  Ok(Some(flowproof_driver::DebugBundle { dom_html, console }))
678
716
  }
679
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
+
680
729
  fn surface_text(&mut self) -> Result<String, DriverError> {
681
730
  // Visible text PLUS the accessible names of visible elements:
682
731
  // icon-only buttons (a command palette, an account menu) exist on
@@ -774,6 +823,15 @@ impl AppDriver for WebAppDriver {
774
823
  let locator = Self::locator(selector)?;
775
824
  let value =
776
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()?;
777
835
  // Playwright's obscured check: does elementFromPoint at the
778
836
  // element's center resolve to it (or a relative)? A toast or
779
837
  // modal backdrop on top makes the click land elsewhere.
@@ -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);
@@ -922,6 +924,10 @@ fn assert_holds(actual: &str, expected: &str, matcher: TextMatch) -> bool {
922
924
  (flowproof_driver::numeric_value(actual), expected.parse::<f64>()),
923
925
  (Some(a), Ok(e)) if a == e
924
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),
925
931
  }
926
932
  }
927
933
 
@@ -1254,13 +1260,18 @@ pub fn record_with_reuse<D: AppDriver, C: ModelClient>(
1254
1260
  let wanted = flowproof_trace::secret::resolve_refs(expected)?;
1255
1261
  let deadline = std::time::Instant::now() + Duration::from_millis(*timeout_ms);
1256
1262
  loop {
1257
- let actual = if selector.is_none() {
1258
- Some(driver.surface_text()?)
1259
- } else if driver.element_exists(targeted())? {
1260
- Some(driver.read_text(targeted())?)
1261
- } else {
1262
- None
1263
- };
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
+ };
1264
1275
  if let Some(actual) = &actual {
1265
1276
  if assert_holds(actual, &wanted, *matcher) {
1266
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).
@@ -451,6 +457,35 @@ mod assertions {
451
457
  .unwrap_or(std::borrow::Cow::Borrowed(trimmed));
452
458
  let trimmed = trimmed.as_ref();
453
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
+
454
489
  if let Some(rest) = strip_prefix_ci(trimmed, "page shows ") {
455
490
  let (expected, count) = split_count(rest.trim());
456
491
  if expected.is_empty() {
@@ -563,7 +598,8 @@ mod assertions {
563
598
 
564
599
  Err(unresolvable(
565
600
  trimmed,
566
- "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 \
567
603
  <text>', 'the \"<label>\" field contains <text>', 'the \"<target>\" shows \
568
604
  <text>', 'the \"<target>\" is [not] visible', or 'the \"<target>\" is \
569
605
  enabled|disabled' (see docs/authoring.md for the full grammar)",
@@ -379,17 +379,45 @@ fn apply_env_from(manifest: &flowproof_agent::SuiteManifest, dir: &Path) -> Resu
379
379
  let Some(command) = &manifest.env_from else {
380
380
  return Ok(());
381
381
  };
382
- let output = std::process::Command::new("sh")
383
- .arg("-c")
384
- .arg(command)
385
- .current_dir(dir)
382
+ // The data command SEES the suite's `env:`. Minting test data almost
383
+ // always needs the suite's base URL and credentials, and before this
384
+ // the command ran with none of them: a mint script reading $API_BASE
385
+ // got an empty string and failed closed downstream, which cost real
386
+ // diagnosis time in the field.
387
+ //
388
+ // Two orderings are easy to conflate, and only the second changes:
389
+ // 1. which value wins for `${VAR}` at flow time - UNCHANGED, still
390
+ // process env < env_from output < `env:`;
391
+ // 2. what the env_from CHILD PROCESS sees - now `env:` too.
392
+ // Entries are resolved against the ambient process environment only,
393
+ // and one that does not resolve yet is skipped rather than fatal: it
394
+ // may reference this very command's output, and it gets its turn when
395
+ // `env:` is applied afterwards.
396
+ let mut child = std::process::Command::new("sh");
397
+ child.arg("-c").arg(command).current_dir(dir);
398
+ for (key, value) in &manifest.env {
399
+ if let Ok(resolved) = flowproof_trace::secret::resolve_refs(value) {
400
+ child.env(key, resolved);
401
+ }
402
+ }
403
+ let output = child
386
404
  .output()
387
405
  .map_err(|e| format!("env_from command failed to start: {e}"))?;
406
+ // The command's stderr is TEED, not swallowed and not merely inherited:
407
+ // echoed so a mint script that explains itself is audible even when it
408
+ // succeeds (half the field diagnosis cost was a script with no voice),
409
+ // AND kept in the error below so a programmatic caller still gets the
410
+ // reason. Inheriting alone would have made the failure message say only
411
+ // "see above", which is useless to `--json`.
412
+ let stderr = String::from_utf8_lossy(&output.stderr);
413
+ if !stderr.trim().is_empty() {
414
+ eprint!("{stderr}");
415
+ }
388
416
  if !output.status.success() {
389
417
  return Err(format!(
390
418
  "env_from command exited with {}: {}",
391
419
  output.status.code().unwrap_or(-1),
392
- String::from_utf8_lossy(&output.stderr).trim()
420
+ stderr.trim()
393
421
  ));
394
422
  }
395
423
  for (name, value) in parse_env_lines(&String::from_utf8_lossy(&output.stdout))? {
@@ -139,3 +139,99 @@ fn spec_without_a_manifest_is_a_noop() {
139
139
  flowproof_cli::apply_suite_context(&spec).expect("no manifest is fine");
140
140
  std::fs::remove_dir_all(&dir).ok();
141
141
  }
142
+
143
+ /// Field regression (cypress-realworld-app, round 3): a mint script that
144
+ /// logs in over HTTP to produce a session needs the suite's own base URL
145
+ /// and password, and got neither - the data command ran with `env:` not yet
146
+ /// applied, so `$RWA_API` was empty and the flow failed closed downstream
147
+ /// with a 401. Minting test data almost always needs the suite's config,
148
+ /// so this is the common case rather than an edge.
149
+ #[test]
150
+ fn env_from_command_sees_the_suites_own_env() {
151
+ let dir = suite_dir("sees-env");
152
+ std::fs::write(
153
+ dir.join("suite.yaml"),
154
+ "env:\n EF_BASE: http://example.test\nenv_from: printf 'EF_MINTED=%s/minted\\n' \"$EF_BASE\"\n",
155
+ )
156
+ .expect("manifest");
157
+ std::fs::write(
158
+ dir.join("probe.flow.yaml"),
159
+ "name: Probe\napp: api\nsteps:\n - assert_api:\n request: GET ${EF_MINTED}\n status: 200\n",
160
+ )
161
+ .expect("spec");
162
+
163
+ // Running the suite applies env_from; the command must have seen
164
+ // EF_BASE and composed the minted value from it.
165
+ flowproof_cli::run_cli(["run", dir.to_str().expect("utf8")]);
166
+ assert_eq!(
167
+ std::env::var("EF_MINTED").as_deref(),
168
+ Ok("http://example.test/minted"),
169
+ "the data command must see the suite's env: block"
170
+ );
171
+
172
+ std::env::remove_var("EF_MINTED");
173
+ std::env::remove_var("EF_BASE");
174
+ std::fs::remove_dir_all(&dir).ok();
175
+ }
176
+
177
+ /// The OTHER ordering is unchanged, and the two are easy to conflate:
178
+ /// what the command SEES changed, but which value WINS for `${VAR}` at flow
179
+ /// time did not - `env:` still overrides the data command's output.
180
+ #[test]
181
+ fn env_precedence_is_unchanged_env_still_wins_over_env_from() {
182
+ let dir = suite_dir("precedence");
183
+ std::fs::write(
184
+ dir.join("suite.yaml"),
185
+ "env:\n EP_SHARED: from-env-block\nenv_from: echo EP_SHARED=from-data-command\n",
186
+ )
187
+ .expect("manifest");
188
+ std::fs::write(
189
+ dir.join("probe.flow.yaml"),
190
+ "name: Probe\napp: api\nsteps:\n - assert_api:\n request: GET http://127.0.0.1:1/x\n status: 200\n",
191
+ )
192
+ .expect("spec");
193
+
194
+ flowproof_cli::run_cli(["run", dir.to_str().expect("utf8")]);
195
+ assert_eq!(
196
+ std::env::var("EP_SHARED").as_deref(),
197
+ Ok("from-env-block"),
198
+ "env: must still win over env_from output"
199
+ );
200
+
201
+ std::env::remove_var("EP_SHARED");
202
+ std::fs::remove_dir_all(&dir).ok();
203
+ }
204
+
205
+ /// An `env:` entry that cannot resolve YET must not break the data command:
206
+ /// it may reference that very command's output, and it gets its turn when
207
+ /// `env:` is applied afterwards.
208
+ #[test]
209
+ fn an_unresolvable_env_entry_does_not_break_the_data_command() {
210
+ let dir = suite_dir("forward-ref");
211
+ std::fs::write(
212
+ dir.join("suite.yaml"),
213
+ "env:\n FR_DERIVED: ${FR_MINTED}/derived\nenv_from: echo FR_MINTED=http://minted.test\n",
214
+ )
215
+ .expect("manifest");
216
+ std::fs::write(
217
+ dir.join("probe.flow.yaml"),
218
+ "name: Probe\napp: api\nsteps:\n - assert_api:\n request: GET ${FR_DERIVED}\n status: 200\n",
219
+ )
220
+ .expect("spec");
221
+
222
+ flowproof_cli::run_cli(["run", dir.to_str().expect("utf8")]);
223
+ assert_eq!(
224
+ std::env::var("FR_MINTED").as_deref(),
225
+ Ok("http://minted.test"),
226
+ "the data command still ran"
227
+ );
228
+ assert_eq!(
229
+ std::env::var("FR_DERIVED").as_deref(),
230
+ Ok("http://minted.test/derived"),
231
+ "an entry referencing the command's own output resolves afterwards"
232
+ );
233
+
234
+ std::env::remove_var("FR_MINTED");
235
+ std::env::remove_var("FR_DERIVED");
236
+ std::fs::remove_dir_all(&dir).ok();
237
+ }
@@ -1166,3 +1166,176 @@ fn label_association_case_fold_and_aria_names_resolve() {
1166
1166
 
1167
1167
  std::fs::remove_dir_all(&dir).ok();
1168
1168
  }
1169
+
1170
+ /// Field regression (cypress-realworld-app, round 3): EVERY flow that logged
1171
+ /// in recorded fine and then failed to replay with "Unable to make method
1172
+ /// calls because underlying connection is closed". The mechanism is a
1173
+ /// self-inflicted transport death, and it needs two ingredients this test
1174
+ /// reproduces in order:
1175
+ ///
1176
+ /// 1. more than 30 seconds of page-level work with no BROWSER-level event,
1177
+ /// which lets headless_chrome's default `idle_browser_timeout` reap the
1178
+ /// browser-event listener thread;
1179
+ /// 2. a real navigation afterwards, which fires `TargetInfoChanged` - a
1180
+ /// browser-level event the transport can no longer deliver, so it treats
1181
+ /// it as fatal and shuts the whole connection down permanently.
1182
+ ///
1183
+ /// A login redirect is exactly that shape, which is why the field suite hit
1184
+ /// it on every authenticated flow and never on the others. Slow by nature
1185
+ /// (it must out-wait the idle reaper); that is the bug.
1186
+ #[test]
1187
+ fn a_navigation_after_a_long_idle_does_not_kill_the_connection() {
1188
+ if std::env::var("FLOWPROOF_E2E").as_deref() != Ok("1") {
1189
+ eprintln!("skipping web idle-then-navigate E2E test: set FLOWPROOF_E2E=1 to run it");
1190
+ return;
1191
+ }
1192
+
1193
+ let dir = std::env::temp_dir().join("flowproof-web-idle-nav-e2e");
1194
+ std::fs::remove_dir_all(&dir).ok();
1195
+ std::fs::create_dir_all(&dir).expect("temp dir");
1196
+ // Page two is a separate document, so reaching it is a real navigation
1197
+ // rather than a same-document update.
1198
+ std::fs::write(
1199
+ dir.join("two.html"),
1200
+ r#"<!doctype html><html><body><h1>Welcome back</h1></body></html>"#,
1201
+ )
1202
+ .expect("page two written");
1203
+ let page = dir.join("one.html");
1204
+ std::fs::write(
1205
+ &page,
1206
+ r#"<!doctype html><html><body>
1207
+ <div id="out">waiting</div>
1208
+ <button id="go" onclick="window.location.href = 'two.html'">Continue</button>
1209
+ <script>
1210
+ // Page-level churn only: no browser-level CDP events at all,
1211
+ // so the idle reaper is free to fire while the flow works.
1212
+ setTimeout(() => {
1213
+ document.getElementById('out').textContent = 'ready to continue';
1214
+ }, 35000);
1215
+ </script>
1216
+ </body></html>"#,
1217
+ )
1218
+ .expect("page one written");
1219
+ let trace_path = dir.join("idle-nav.trace.jsonl");
1220
+
1221
+ let spec = flowproof_agent::FlowSpec {
1222
+ name: "Navigate after a long idle".into(),
1223
+ app: "web".into(),
1224
+ url: Some(format!("file://{}", page.display())),
1225
+ redact: vec![],
1226
+ connection: None,
1227
+ window: None,
1228
+ session: None,
1229
+ skip_unless_env: Vec::new(),
1230
+ mock: Vec::new(),
1231
+ browser: None,
1232
+ steps: vec![
1233
+ // Out-waits the 30s default idle timeout.
1234
+ flowproof_agent::SpecStep::Plain(
1235
+ "Wait until page shows ready to continue within 60s".into(),
1236
+ ),
1237
+ flowproof_agent::SpecStep::Plain("Press the \"Continue\" button".into()),
1238
+ // The read that used to die: first page-level call after the
1239
+ // navigation's TargetInfoChanged.
1240
+ flowproof_agent::SpecStep::Plain(
1241
+ "Wait until page shows Welcome back within 20s".into(),
1242
+ ),
1243
+ ],
1244
+ };
1245
+
1246
+ let mut driver = flowproof_cli::driver_for("web").expect("browser launches");
1247
+ flowproof_agent::record(&spec, &mut driver, &trace_path).expect("recording survives the idle");
1248
+ drop(driver);
1249
+
1250
+ let mut driver = flowproof_cli::driver_for("web").expect("browser launches");
1251
+ let (report, _run_dir) =
1252
+ flowproof_replay::run_trace(&trace_path, &mut driver).expect("replay runs");
1253
+ assert!(
1254
+ report.passed,
1255
+ "a navigation after an idle period must not kill the transport: {report:#?}"
1256
+ );
1257
+
1258
+ std::fs::remove_dir_all(&dir).ok();
1259
+ }
1260
+
1261
+ /// Field regression (cypress-realworld-app, round 3): a settings form whose
1262
+ /// first field sat below the fold was untestable. The actionability gate
1263
+ /// hit-tests `elementFromPoint` at the element's centre, but ran BEFORE any
1264
+ /// scrolling - and outside the viewport that returns null, so the gate
1265
+ /// reported "obscured (another element would receive the click)" and
1266
+ /// blocked a click that would have worked. headless_chrome's own
1267
+ /// `Element::click` starts with `scroll_into_view`, so the gate was asking
1268
+ /// about a position the click never uses. Cypress and Playwright both
1269
+ /// scroll before acting; now so does the gate.
1270
+ #[test]
1271
+ fn an_element_below_the_fold_is_scrolled_to_rather_than_called_obscured() {
1272
+ if std::env::var("FLOWPROOF_E2E").as_deref() != Ok("1") {
1273
+ eprintln!("skipping web below-the-fold E2E test: set FLOWPROOF_E2E=1 to run it");
1274
+ return;
1275
+ }
1276
+
1277
+ let dir = std::env::temp_dir().join("flowproof-web-below-fold-e2e");
1278
+ std::fs::remove_dir_all(&dir).ok();
1279
+ std::fs::create_dir_all(&dir).expect("temp dir");
1280
+ let page = dir.join("tall.html");
1281
+ // A tall spacer puts the field far below a pinned 500x300 viewport, so
1282
+ // "below the fold" is a property of the test, not of the runner.
1283
+ std::fs::write(
1284
+ &page,
1285
+ r#"<!doctype html><html><body style="margin:0">
1286
+ <div style="height:1200px">scroll down</div>
1287
+ <input id="name" placeholder="Full name" />
1288
+ <button id="save" onclick="
1289
+ document.getElementById('out').textContent = 'Saved ' + document.getElementById('name').value;
1290
+ ">Save</button>
1291
+ <div id="out"></div>
1292
+ </body></html>"#,
1293
+ )
1294
+ .expect("page written");
1295
+ let trace_path = dir.join("tall.trace.jsonl");
1296
+
1297
+ let spec = flowproof_agent::FlowSpec {
1298
+ name: "Field below the fold".into(),
1299
+ app: "web".into(),
1300
+ url: Some(format!("file://{}", page.display())),
1301
+ redact: vec![],
1302
+ connection: None,
1303
+ window: None,
1304
+ session: None,
1305
+ skip_unless_env: Vec::new(),
1306
+ mock: Vec::new(),
1307
+ browser: Some(flowproof_trace::format::BrowserSetup {
1308
+ viewport: Some(flowproof_trace::format::ViewportSetup {
1309
+ width: 500,
1310
+ height: 300,
1311
+ device_scale_factor: None,
1312
+ mobile: None,
1313
+ touch: None,
1314
+ }),
1315
+ user_agent: None,
1316
+ args: Vec::new(),
1317
+ }),
1318
+ steps: vec![
1319
+ flowproof_agent::SpecStep::Plain("Type Ada into the name field".into()),
1320
+ flowproof_agent::SpecStep::Plain("Press the \"Save\" button".into()),
1321
+ flowproof_agent::SpecStep::Assert {
1322
+ assert: "page shows Saved Ada".into(),
1323
+ },
1324
+ ],
1325
+ };
1326
+
1327
+ let mut driver = flowproof_cli::driver_for("web").expect("browser launches");
1328
+ flowproof_agent::record(&spec, &mut driver, &trace_path)
1329
+ .expect("recording reaches a field below the fold");
1330
+ drop(driver);
1331
+
1332
+ let mut driver = flowproof_cli::driver_for("web").expect("browser launches");
1333
+ let (report, _run_dir) =
1334
+ flowproof_replay::run_trace(&trace_path, &mut driver).expect("replay runs");
1335
+ assert!(
1336
+ report.passed,
1337
+ "below-the-fold element must be scrolled to, not called obscured: {report:#?}"
1338
+ );
1339
+
1340
+ std::fs::remove_dir_all(&dir).ok();
1341
+ }
@@ -211,6 +211,16 @@ pub trait AppDriver {
211
211
  ))
212
212
  }
213
213
 
214
+ /// The surface's current location, for `page url is|contains`. Only a
215
+ /// browser has one: a UIA window, a SAP session and an OCR'd frame do
216
+ /// not, so the default refuses with a reason rather than inventing an
217
+ /// empty string that would make assertions quietly pass.
218
+ fn current_url(&mut self) -> Result<String, DriverError> {
219
+ Err(DriverError::Uia(
220
+ "this app has no URL: `page url` assertions are for web flows".into(),
221
+ ))
222
+ }
223
+
214
224
  /// Stage session state (cookies, localStorage) to be applied by the
215
225
  /// NEXT `launch` before the page loads.
216
226
  fn stage_session(&mut self, _session: WebSession) -> Result<(), DriverError> {
@@ -430,6 +440,70 @@ pub struct AppTarget {
430
440
  /// Extract the trailing numeric value from display text like
431
441
  /// `Display is 8` or `1,234.5`. Shared by record and replay so both phases
432
442
  /// judge display values identically.
443
+ /// Does `actual` (a full URL) satisfy a `page url` expectation?
444
+ ///
445
+ /// Shared by record and replay - like [`numeric_value`] - because a URL
446
+ /// assertion that holds while recording must hold when replayed, and two
447
+ /// copies of a matcher drift (that is exactly how the round-3 field
448
+ /// migration produced traces that could not replay).
449
+ ///
450
+ /// `exact = false` is `page url contains <text>`: a plain substring test
451
+ /// over the whole href.
452
+ ///
453
+ /// `exact = true` is `page url is <expected>`, which is deliberately
454
+ /// path-shaped, because `cy.location("pathname").should("equal", "/signin")`
455
+ /// is the assertion people actually write:
456
+ ///
457
+ /// - `expected` containing `://` compares against the WHOLE url, exactly;
458
+ /// - `expected` starting with `/` compares against the pathname, and
459
+ /// includes the query only when `expected` carries a `?`, the fragment
460
+ /// only when it carries a `#`. So `/orders` ignores `?page=2`, while
461
+ /// `/orders?page=2` does not;
462
+ /// - anything else compares against the whole url, exactly. There is no
463
+ /// guessing: an expectation that is neither a path nor a full URL is
464
+ /// most likely a mistake, and an exact comparison says so loudly.
465
+ pub fn url_matches(expected: &str, exact: bool, actual: &str) -> bool {
466
+ if !exact {
467
+ return actual.contains(expected);
468
+ }
469
+ if expected.contains("://") || !expected.starts_with('/') {
470
+ return actual == expected;
471
+ }
472
+ let (want_query, want_hash) = (expected.contains('?'), expected.contains('#'));
473
+ // Split the live URL into path / query / fragment without pulling in a
474
+ // URL crate: after the scheme's `://`, the path starts at the first
475
+ // `/`, and `?` / `#` bound it from the right.
476
+ let after_scheme = match actual.find("://") {
477
+ Some(i) => &actual[i + 3..],
478
+ None => actual,
479
+ };
480
+ let from_path = match after_scheme.find('/') {
481
+ Some(i) => &after_scheme[i..],
482
+ // No path at all ("https://example.test") means the root.
483
+ None => "/",
484
+ };
485
+ let hash_at = from_path.find('#');
486
+ let without_hash = match hash_at {
487
+ Some(i) => &from_path[..i],
488
+ None => from_path,
489
+ };
490
+ let hash = hash_at.map(|i| &from_path[i..]).unwrap_or("");
491
+ let query_at = without_hash.find('?');
492
+ let path = match query_at {
493
+ Some(i) => &without_hash[..i],
494
+ None => without_hash,
495
+ };
496
+ let query = query_at.map(|i| &without_hash[i..]).unwrap_or("");
497
+ let mut candidate = String::from(path);
498
+ if want_query {
499
+ candidate.push_str(query);
500
+ }
501
+ if want_hash {
502
+ candidate.push_str(hash);
503
+ }
504
+ candidate == expected
505
+ }
506
+
433
507
  pub fn numeric_value(text: &str) -> Option<f64> {
434
508
  text.split_whitespace()
435
509
  .rev()
@@ -547,6 +621,10 @@ impl AppDriver for Box<dyn AppDriver> {
547
621
  (**self).surface_text()
548
622
  }
549
623
 
624
+ fn current_url(&mut self) -> Result<String, DriverError> {
625
+ (**self).current_url()
626
+ }
627
+
550
628
  fn stage_session(&mut self, session: WebSession) -> Result<(), DriverError> {
551
629
  (**self).stage_session(session)
552
630
  }
@@ -1094,3 +1172,103 @@ mod tests {
1094
1172
  assert!(matches!(err, DriverError::UnsupportedPlatform));
1095
1173
  }
1096
1174
  }
1175
+
1176
+ #[cfg(test)]
1177
+ mod url_matches_tests {
1178
+ use super::url_matches;
1179
+
1180
+ /// `page url is /path` is path-shaped on purpose: it maps
1181
+ /// `cy.location("pathname").should("equal", "/signin")` one to one.
1182
+ #[test]
1183
+ fn a_path_expectation_compares_the_pathname_only() {
1184
+ assert!(url_matches("/signin", true, "http://localhost:3000/signin"));
1185
+ assert!(url_matches("/signin", true, "https://app.test/signin#top"));
1186
+ // The query is ignored unless the expectation asks for one.
1187
+ assert!(url_matches(
1188
+ "/orders",
1189
+ true,
1190
+ "https://app.test/orders?page=2"
1191
+ ));
1192
+ assert!(!url_matches("/signin", true, "https://app.test/signup"));
1193
+ // A path expectation must not match a mere prefix.
1194
+ assert!(!url_matches("/order", true, "https://app.test/orders"));
1195
+ }
1196
+
1197
+ #[test]
1198
+ fn a_query_or_fragment_in_the_expectation_makes_it_significant() {
1199
+ assert!(url_matches(
1200
+ "/orders?page=2",
1201
+ true,
1202
+ "https://app.test/orders?page=2"
1203
+ ));
1204
+ assert!(!url_matches(
1205
+ "/orders?page=2",
1206
+ true,
1207
+ "https://app.test/orders?page=3"
1208
+ ));
1209
+ assert!(!url_matches(
1210
+ "/orders?page=2",
1211
+ true,
1212
+ "https://app.test/orders"
1213
+ ));
1214
+ assert!(url_matches(
1215
+ "/docs#install",
1216
+ true,
1217
+ "https://app.test/docs#install"
1218
+ ));
1219
+ assert!(!url_matches(
1220
+ "/docs#install",
1221
+ true,
1222
+ "https://app.test/docs#usage"
1223
+ ));
1224
+ }
1225
+
1226
+ #[test]
1227
+ fn a_full_url_expectation_compares_the_whole_url() {
1228
+ assert!(url_matches(
1229
+ "https://app.test/signin",
1230
+ true,
1231
+ "https://app.test/signin"
1232
+ ));
1233
+ // Different scheme or host is a different URL, even at the same path.
1234
+ assert!(!url_matches(
1235
+ "https://app.test/signin",
1236
+ true,
1237
+ "http://app.test/signin"
1238
+ ));
1239
+ }
1240
+
1241
+ #[test]
1242
+ fn contains_is_a_plain_substring_of_the_whole_url() {
1243
+ assert!(url_matches(
1244
+ "checkout",
1245
+ false,
1246
+ "https://app.test/cart/checkout?step=1"
1247
+ ));
1248
+ assert!(url_matches("app.test", false, "https://app.test/cart"));
1249
+ assert!(!url_matches("checkout", false, "https://app.test/cart"));
1250
+ }
1251
+
1252
+ /// Edge shapes that must not panic or silently mis-compare: a bare
1253
+ /// origin has the root path, and non-ASCII paths are byte-safe.
1254
+ #[test]
1255
+ fn origins_and_multibyte_paths_are_handled() {
1256
+ assert!(url_matches("/", true, "https://app.test"));
1257
+ assert!(url_matches("/", true, "https://app.test/"));
1258
+ assert!(url_matches(
1259
+ "/konto/überweisung",
1260
+ true,
1261
+ "https://app.test/konto/überweisung"
1262
+ ));
1263
+ assert!(url_matches(
1264
+ "überweisung",
1265
+ false,
1266
+ "https://app.test/konto/überweisung"
1267
+ ));
1268
+ assert!(!url_matches(
1269
+ "/konto/überweisung",
1270
+ true,
1271
+ "https://app.test/konto/uberweisung"
1272
+ ));
1273
+ }
1274
+ }
@@ -19,9 +19,9 @@ pub mod visual;
19
19
  pub mod window;
20
20
 
21
21
  pub use app::{
22
- absolute_url, numeric_value, resolve_app, url_origin, AppDriver, AppTarget, DebugBundle,
23
- KeyMod, NoOpDriver, PixelRect, UiaAppDriver, UiaSelector, WebBrowserConfig, WebMock,
24
- WebSession, WebViewport,
22
+ absolute_url, numeric_value, resolve_app, url_matches, url_origin, AppDriver, AppTarget,
23
+ DebugBundle, KeyMod, NoOpDriver, PixelRect, UiaAppDriver, UiaSelector, WebBrowserConfig,
24
+ WebMock, WebSession, WebViewport,
25
25
  };
26
26
  pub use backend::PlatformBackend;
27
27
  pub use recording::{FrameRef, Recording, RunRecorder, StepTiming};
@@ -69,6 +69,8 @@ pub struct MockAppDriver {
69
69
  /// Browser config captured by `stage_browser` — the mock stands in
70
70
  /// for the web driver here, so tests can assert the staging happened.
71
71
  pub staged_browser: Option<crate::WebBrowserConfig>,
72
+ /// URL reported by `current_url`, for `page url is|contains` tests.
73
+ pub url: Option<String>,
72
74
  /// Leading `surface_text` calls that fail with a TRANSPORT fault before
73
75
  /// any real answer — a dead CDP socket during an auto-wait poll.
74
76
  /// `u32::MAX` never recovers, which is how the "budget expired without
@@ -98,6 +100,11 @@ impl MockAppDriver {
98
100
  self
99
101
  }
100
102
 
103
+ pub fn with_url(mut self, url: &str) -> Self {
104
+ self.url = Some(url.into());
105
+ self
106
+ }
107
+
101
108
  /// Fail the next `count` surface reads with a transport fault. Models
102
109
  /// the field case behind GAP-A: the socket dies mid-flow while the app
103
110
  /// itself is fine, so a poll learns nothing and must not fail the step.
@@ -189,6 +196,12 @@ impl AppDriver for MockAppDriver {
189
196
  Ok(())
190
197
  }
191
198
 
199
+ fn current_url(&mut self) -> Result<String, DriverError> {
200
+ self.url
201
+ .clone()
202
+ .ok_or_else(|| DriverError::Uia("mock has no url".into()))
203
+ }
204
+
192
205
  fn surface_text(&mut self) -> Result<String, DriverError> {
193
206
  if self.surface_faults > 0 {
194
207
  // u32::MAX means "never recovers": leave it saturated.
@@ -439,6 +439,65 @@ where
439
439
  }
440
440
  }
441
441
 
442
+ /// The `page url` expectation carried by an `element_state`, if any:
443
+ /// `(expected, exact)` where exact distinguishes `is` from `contains`.
444
+ fn url_expectation(expect: &serde_json::Value) -> Option<(&str, bool)> {
445
+ expect
446
+ .get("url_equals")
447
+ .and_then(|v| v.as_str())
448
+ .map(|e| (e, true))
449
+ .or_else(|| {
450
+ expect
451
+ .get("url_contains")
452
+ .and_then(|v| v.as_str())
453
+ .map(|e| (e, false))
454
+ })
455
+ }
456
+
457
+ /// Poll the surface's URL until the expectation holds or `deadline` passes.
458
+ /// Mirrors [`check_text_expectation`]: transport faults are misses, a
459
+ /// budget that expires with no reading at all is a driver error, and the
460
+ /// message keeps the RAW expectation so a `${VAR}` never leaks.
461
+ fn check_url_expectation<F>(
462
+ expect: &serde_json::Value,
463
+ raw: &str,
464
+ exact: bool,
465
+ deadline: Instant,
466
+ mut read: F,
467
+ ) -> Result<(Result<(), String>, Option<usize>), ReplayError>
468
+ where
469
+ F: FnMut() -> Result<String, flowproof_driver::DriverError>,
470
+ {
471
+ let _ = expect;
472
+ let expected = match flowproof_trace::secret::resolve_refs(raw) {
473
+ Ok(expected) => expected,
474
+ Err(e) => return Ok((Err(e.to_string()), None)),
475
+ };
476
+ let mut fault: Option<flowproof_driver::DriverError> = None;
477
+ let mut last: Option<String> = None;
478
+ loop {
479
+ if let Some(url) = tolerate(read(), &mut fault)? {
480
+ if flowproof_driver::url_matches(&expected, exact, &url) {
481
+ return Ok((Ok(()), None));
482
+ }
483
+ last = Some(url);
484
+ }
485
+ if Instant::now() >= deadline {
486
+ let Some(url) = last else {
487
+ return Err(exhausted(fault));
488
+ };
489
+ let shown = if flowproof_trace::secret::has_refs(raw) {
490
+ "<masked>".to_string()
491
+ } else {
492
+ url
493
+ };
494
+ let verb = if exact { "url" } else { "url containing" };
495
+ return Ok((Err(format!("expected {verb} '{raw}', got '{shown}'")), None));
496
+ }
497
+ std::thread::sleep(POLL_INTERVAL);
498
+ }
499
+ }
500
+
442
501
  /// One reading inside an auto-wait poll loop. A [`DriverError::Transport`]
443
502
  /// fault is a MISS (`Ok(None)`) rather than an error: the assertion's
444
503
  /// contract is "this holds within N seconds", and a dead socket on one
@@ -515,6 +574,15 @@ fn check_assertion<D: AppDriver>(
515
574
  // Surface-scoped: no selector to resolve — every adapter
516
575
  // answers `surface_text` its own way (page / window subtree /
517
576
  // OCR frame).
577
+ // `page url is|contains` reads the surface's LOCATION rather
578
+ // than its text. Same poll, same recorded bound: an SPA
579
+ // redirect lands asynchronously, so this must auto-wait like
580
+ // everything else.
581
+ if let Some((raw, exact)) = url_expectation(expect) {
582
+ return check_url_expectation(expect, raw, exact, deadline, || {
583
+ driver.current_url()
584
+ });
585
+ }
518
586
  if expect.get("scope").and_then(|v| v.as_str()) == Some("surface") {
519
587
  return check_text_expectation(expect, deadline, None, || driver.surface_text());
520
588
  }
@@ -1245,3 +1245,95 @@ fn transport_fault_tolerance_preserves_multibyte_surface_text() {
1245
1245
 
1246
1246
  std::fs::remove_dir_all(&dir).ok();
1247
1247
  }
1248
+
1249
+ /// GAP-D (field: cypress-realworld-app). `cy.location("pathname")
1250
+ /// .should("equal","/signin")` is the most common assertion in that suite
1251
+ /// and had no equivalent - the workaround was asserting on visible text,
1252
+ /// which passes even when the route is wrong. Record and replay must agree
1253
+ /// on what the URL forms mean, so both run through the same matcher.
1254
+ #[test]
1255
+ fn url_assertions_record_and_replay_through_one_matcher() {
1256
+ let dir = std::env::temp_dir().join("flowproof-replay-url");
1257
+ std::fs::create_dir_all(&dir).expect("temp dir");
1258
+ let spec = FlowSpec::parse(
1259
+ "name: Redirect\napp: web\nurl: x\nsteps:\n - assert: page url is /signin\n - assert: page url contains signin\n",
1260
+ )
1261
+ .expect("spec parses");
1262
+ let spec = FlowSpec {
1263
+ url: Some("https://app.test/personal".into()),
1264
+ ..spec
1265
+ };
1266
+ let trace = dir.join("url.trace.jsonl");
1267
+
1268
+ // Recording verifies against the live URL, exactly as replay will.
1269
+ let mut rec = MockAppDriver::new(&[]).with_url("https://app.test/signin?next=%2Fpersonal");
1270
+ record(&spec, &mut rec, &trace).expect("recording verifies the url");
1271
+
1272
+ // The trace carries the expectation, not the resolved location.
1273
+ let persisted = std::fs::read_to_string(&trace).expect("trace readable");
1274
+ assert!(
1275
+ persisted.contains("\"url_equals\":\"/signin\""),
1276
+ "{persisted}"
1277
+ );
1278
+ assert!(
1279
+ persisted.contains("\"url_contains\":\"signin\""),
1280
+ "{persisted}"
1281
+ );
1282
+
1283
+ // Replay: same URL, passes. The query is ignored because the
1284
+ // expectation carries no `?`.
1285
+ let mut driver = MockAppDriver::new(&[]).with_url("https://app.test/signin?next=%2Fpersonal");
1286
+ let (report, _run_dir) = run_trace(&trace, &mut driver).expect("replay runs");
1287
+ assert!(report.passed, "report: {report:#?}");
1288
+
1289
+ // A different route fails, and the failure names what it saw - the
1290
+ // whole point of the assertion over a text proxy.
1291
+ let mut driver = MockAppDriver::new(&[]).with_url("https://app.test/signup");
1292
+ let (report, _run_dir) = run_trace(&trace, &mut driver).expect("replay runs");
1293
+ assert!(!report.passed);
1294
+ let detail = report.steps[0].detail.clone().unwrap_or_default();
1295
+ assert!(detail.contains("/signin"), "detail: {detail}");
1296
+ assert!(
1297
+ detail.contains("signup"),
1298
+ "detail names the live url: {detail}"
1299
+ );
1300
+
1301
+ std::fs::remove_dir_all(&dir).ok();
1302
+ }
1303
+
1304
+ /// A URL expectation may carry a `${VAR}`, and then neither the trace nor a
1305
+ /// failure message may leak the resolved value.
1306
+ #[test]
1307
+ fn url_assertions_keep_secrets_out_of_the_trace_and_the_message() {
1308
+ let dir = std::env::temp_dir().join("flowproof-replay-url-secret");
1309
+ std::fs::create_dir_all(&dir).expect("temp dir");
1310
+ std::env::set_var("FP_URL_TENANT", "acme-7f3c");
1311
+ let spec = FlowSpec::parse(
1312
+ "name: Tenant\napp: web\nurl: x\nsteps:\n - assert: page url contains ${FP_URL_TENANT}\n",
1313
+ )
1314
+ .expect("spec parses");
1315
+ let spec = FlowSpec {
1316
+ url: Some("https://app.test/".into()),
1317
+ ..spec
1318
+ };
1319
+ let trace = dir.join("secret.trace.jsonl");
1320
+ let mut rec = MockAppDriver::new(&[]).with_url("https://app.test/t/acme-7f3c/home");
1321
+ record(&spec, &mut rec, &trace).expect("recording resolves the ref");
1322
+
1323
+ let persisted = std::fs::read_to_string(&trace).expect("trace readable");
1324
+ assert!(persisted.contains("${FP_URL_TENANT}"), "raw ref travels");
1325
+ assert!(
1326
+ !persisted.contains("acme-7f3c"),
1327
+ "resolved value must not: {persisted}"
1328
+ );
1329
+
1330
+ let mut driver = MockAppDriver::new(&[]).with_url("https://app.test/t/other/home");
1331
+ let (report, _run_dir) = run_trace(&trace, &mut driver).expect("replay runs");
1332
+ assert!(!report.passed);
1333
+ let detail = report.steps[0].detail.clone().unwrap_or_default();
1334
+ assert!(!detail.contains("acme-7f3c"), "failure must mask: {detail}");
1335
+ assert!(detail.contains("<masked>"), "detail: {detail}");
1336
+
1337
+ std::env::remove_var("FP_URL_TENANT");
1338
+ std::fs::remove_dir_all(&dir).ok();
1339
+ }
@@ -24,7 +24,7 @@ from flowproof.flow import (
24
24
  run,
25
25
  )
26
26
 
27
- __version__ = "0.2.4"
27
+ __version__ = "0.2.5"
28
28
 
29
29
  __all__ = [
30
30
  "ClarificationNeeded",
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "flowproof"
7
- version = "0.2.4"
7
+ version = "0.2.5"
8
8
  description = "Generic open-source automation framework for the AI-agent era: automated testing and agentic process automation across web, desktop, and Citrix."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
File without changes
File without changes
File without changes
File without changes