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