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