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.
- {flowproof-0.2.4 → flowproof-0.2.5}/Cargo.lock +7 -7
- {flowproof-0.2.4 → flowproof-0.2.5}/Cargo.toml +1 -1
- {flowproof-0.2.4 → flowproof-0.2.5}/PKG-INFO +1 -1
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/src/web.rs +58 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/recorder.rs +18 -7
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/rules.rs +37 -1
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/src/lib.rs +33 -5
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/suite_env_from.rs +96 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/web_e2e.rs +173 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/app.rs +178 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/lib.rs +3 -3
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/mock.rs +13 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-replay/src/lib.rs +68 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-replay/tests/replay_calc.rs +92 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/flowproof/__init__.py +1 -1
- {flowproof-0.2.4 → flowproof-0.2.5}/pyproject.toml +1 -1
- {flowproof-0.2.4 → flowproof-0.2.5}/README.md +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/src/lib.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/src/sap_com.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-adapters/src/vision.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/author.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/clarify.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/heal.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/lib.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/llm.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-agent/src/spec.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/src/main.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/backend.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/gdi.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/oob.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/recording.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/redact.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/visual.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-driver/src/window.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-python/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-python/src/lib.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-replay/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-replay/src/report.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/Cargo.toml +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/src/format.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/src/lib.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/src/secret.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/flowproof/cli.py +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/flowproof/flow.py +0 -0
- {flowproof-0.2.4 → flowproof-0.2.5}/flowproof/mcp_server.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
6
|
+
version = "0.2.5"
|
|
7
7
|
edition = "2021"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
repository = "https://github.com/automators-com/flowproof"
|
|
@@ -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 =
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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,
|
|
23
|
-
KeyMod, NoOpDriver, PixelRect, UiaAppDriver, UiaSelector, WebBrowserConfig,
|
|
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
|
+
}
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "flowproof"
|
|
7
|
-
version = "0.2.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flowproof-0.2.4 → flowproof-0.2.5}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|