flowproof 0.2.2__tar.gz → 0.2.3__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.2 → flowproof-0.2.3}/Cargo.lock +8 -7
- {flowproof-0.2.2 → flowproof-0.2.3}/Cargo.toml +1 -1
- {flowproof-0.2.2 → flowproof-0.2.3}/PKG-INFO +1 -1
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/src/web.rs +437 -11
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/lib.rs +3 -1
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/recorder.rs +582 -1
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/rules.rs +108 -1
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/spec.rs +89 -5
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/src/lib.rs +53 -13
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/llm_author_e2e.rs +2 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/notepad_author_e2e.rs +2 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/web_e2e.rs +300 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/app.rs +223 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/lib.rs +4 -2
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/mock.rs +76 -0
- flowproof-0.2.3/crates/flowproof-driver/src/visual.rs +206 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-python/src/lib.rs +6 -3
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-replay/Cargo.toml +1 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-replay/src/lib.rs +395 -3
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-replay/tests/replay_calc.rs +285 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/schema/trace-v1.schema.json +66 -3
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/src/format.rs +107 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +1 -1
- {flowproof-0.2.2 → flowproof-0.2.3}/flowproof/__init__.py +1 -1
- {flowproof-0.2.2 → flowproof-0.2.3}/pyproject.toml +1 -1
- {flowproof-0.2.2 → flowproof-0.2.3}/README.md +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/Cargo.toml +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/src/lib.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/src/sap_com.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-adapters/src/vision.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/Cargo.toml +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/author.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/clarify.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/heal.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-agent/src/llm.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/Cargo.toml +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/src/main.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/Cargo.toml +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/backend.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/gdi.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/oob.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/recording.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/redact.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-driver/src/window.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-python/Cargo.toml +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-replay/src/report.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/Cargo.toml +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/src/lib.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/src/secret.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/flowproof/cli.py +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/flowproof/flow.py +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/flowproof/mcp_server.py +0 -0
- {flowproof-0.2.2 → flowproof-0.2.3}/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.3"
|
|
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.3"
|
|
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.3"
|
|
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.3"
|
|
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.3"
|
|
789
789
|
dependencies = [
|
|
790
790
|
"flowproof-agent",
|
|
791
791
|
"flowproof-cli",
|
|
@@ -797,12 +797,13 @@ dependencies = [
|
|
|
797
797
|
|
|
798
798
|
[[package]]
|
|
799
799
|
name = "flowproof-replay"
|
|
800
|
-
version = "0.2.
|
|
800
|
+
version = "0.2.3"
|
|
801
801
|
dependencies = [
|
|
802
802
|
"chrono",
|
|
803
803
|
"flowproof-agent",
|
|
804
804
|
"flowproof-driver",
|
|
805
805
|
"flowproof-trace",
|
|
806
|
+
"image",
|
|
806
807
|
"serde",
|
|
807
808
|
"serde_json",
|
|
808
809
|
"thiserror 2.0.18",
|
|
@@ -810,7 +811,7 @@ dependencies = [
|
|
|
810
811
|
|
|
811
812
|
[[package]]
|
|
812
813
|
name = "flowproof-trace"
|
|
813
|
-
version = "0.2.
|
|
814
|
+
version = "0.2.3"
|
|
814
815
|
dependencies = [
|
|
815
816
|
"jsonschema",
|
|
816
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.3"
|
|
7
7
|
edition = "2021"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
repository = "https://github.com/automators-com/flowproof"
|
|
@@ -12,7 +12,7 @@ use std::time::Duration;
|
|
|
12
12
|
use flowproof_driver::{AppDriver, DriverError, KeyMod, PixelRect, UiaSelector, WebSession};
|
|
13
13
|
use headless_chrome::browser::tab::{ModifierKey, Tab};
|
|
14
14
|
use headless_chrome::protocol::cdp::Target::CreateTarget;
|
|
15
|
-
use headless_chrome::protocol::cdp::{Network, Page};
|
|
15
|
+
use headless_chrome::protocol::cdp::{Emulation, Input, Network, Page};
|
|
16
16
|
use headless_chrome::{Browser, LaunchOptions};
|
|
17
17
|
|
|
18
18
|
use crate::AdapterError;
|
|
@@ -23,10 +23,13 @@ fn web_err(context: &str, err: impl std::fmt::Display) -> DriverError {
|
|
|
23
23
|
DriverError::Uia(format!("web: {context}: {err}"))
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/// Launch a fresh headless Chromium (`CHROME` env var overrides the
|
|
27
|
-
|
|
26
|
+
/// Launch a fresh headless Chromium (`CHROME` env var overrides the
|
|
27
|
+
/// binary), optionally with extra command-line flags.
|
|
28
|
+
fn launch_browser(extra_args: &[String]) -> Result<Browser, AdapterError> {
|
|
29
|
+
let os_args: Vec<std::ffi::OsString> = extra_args.iter().map(Into::into).collect();
|
|
28
30
|
let mut options = LaunchOptions::default_builder();
|
|
29
31
|
options.headless(true).sandbox(false);
|
|
32
|
+
options.args(os_args.iter().map(AsRef::as_ref).collect());
|
|
30
33
|
if let Ok(path) = std::env::var("CHROME") {
|
|
31
34
|
options.path(Some(path.into()));
|
|
32
35
|
}
|
|
@@ -59,7 +62,7 @@ fn shared_browser() -> Result<Browser, AdapterError> {
|
|
|
59
62
|
return Ok(browser.clone());
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
|
-
let browser = launch_browser()?;
|
|
65
|
+
let browser = launch_browser(&[])?;
|
|
63
66
|
let keepalive = browser
|
|
64
67
|
.new_tab()
|
|
65
68
|
.map_err(|e| AdapterError::Web(format!("opening keep-alive tab: {e}")))?;
|
|
@@ -78,6 +81,49 @@ pub struct WebAppDriver {
|
|
|
78
81
|
/// Session staged via [`AppDriver::stage_session`], applied by the next
|
|
79
82
|
/// `launch` before the page loads.
|
|
80
83
|
staged_session: Option<WebSession>,
|
|
84
|
+
/// Recent console/log lines from the page (bounded ring buffer),
|
|
85
|
+
/// filled by a CDP event listener registered at launch — read
|
|
86
|
+
/// retroactively when a step fails ([`AppDriver::debug_bundle`]).
|
|
87
|
+
console: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
|
|
88
|
+
/// Network mocks staged via [`AppDriver::stage_mocks`], installed by
|
|
89
|
+
/// the next `launch` before navigation (CDP Fetch interception).
|
|
90
|
+
staged_mocks: Vec<flowproof_driver::WebMock>,
|
|
91
|
+
/// Browser config staged via [`AppDriver::stage_browser`], applied by
|
|
92
|
+
/// the next `launch`: viewport/UA per-tab; extra flags swap in a
|
|
93
|
+
/// private browser (flags only apply at process start).
|
|
94
|
+
staged_browser: Option<flowproof_driver::WebBrowserConfig>,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Cap on retained console lines — enough context for a failure, bounded
|
|
98
|
+
/// so a chatty app can't balloon the run bundle.
|
|
99
|
+
const CONSOLE_TAIL_CAP: usize = 100;
|
|
100
|
+
|
|
101
|
+
/// Standard base64 for CDP `Fetch.fulfillRequest` bodies — hand-rolled
|
|
102
|
+
/// (~15 lines) rather than pulling a crate into the adapter for one call.
|
|
103
|
+
fn base64_encode(data: &[u8]) -> String {
|
|
104
|
+
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
105
|
+
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
106
|
+
for chunk in data.chunks(3) {
|
|
107
|
+
let b = [
|
|
108
|
+
chunk[0],
|
|
109
|
+
*chunk.get(1).unwrap_or(&0),
|
|
110
|
+
*chunk.get(2).unwrap_or(&0),
|
|
111
|
+
];
|
|
112
|
+
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
|
113
|
+
out.push(ALPHABET[(n >> 18) as usize & 63] as char);
|
|
114
|
+
out.push(ALPHABET[(n >> 12) as usize & 63] as char);
|
|
115
|
+
out.push(if chunk.len() > 1 {
|
|
116
|
+
ALPHABET[(n >> 6) as usize & 63] as char
|
|
117
|
+
} else {
|
|
118
|
+
'='
|
|
119
|
+
});
|
|
120
|
+
out.push(if chunk.len() > 2 {
|
|
121
|
+
ALPHABET[n as usize & 63] as char
|
|
122
|
+
} else {
|
|
123
|
+
'='
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
out
|
|
81
127
|
}
|
|
82
128
|
|
|
83
129
|
impl WebAppDriver {
|
|
@@ -86,10 +132,13 @@ impl WebAppDriver {
|
|
|
86
132
|
pub fn new() -> Result<Self, AdapterError> {
|
|
87
133
|
if std::env::var_os("FLOWPROOF_NO_SHARED_BROWSER").is_some() {
|
|
88
134
|
return Ok(Self {
|
|
89
|
-
browser: launch_browser()?,
|
|
135
|
+
browser: launch_browser(&[])?,
|
|
90
136
|
context_id: None,
|
|
91
137
|
tab: None,
|
|
92
138
|
staged_session: None,
|
|
139
|
+
console: Default::default(),
|
|
140
|
+
staged_mocks: Vec::new(),
|
|
141
|
+
staged_browser: None,
|
|
93
142
|
});
|
|
94
143
|
}
|
|
95
144
|
let browser = shared_browser()?;
|
|
@@ -102,6 +151,9 @@ impl WebAppDriver {
|
|
|
102
151
|
context_id: Some(context_id),
|
|
103
152
|
tab: None,
|
|
104
153
|
staged_session: None,
|
|
154
|
+
console: Default::default(),
|
|
155
|
+
staged_mocks: Vec::new(),
|
|
156
|
+
staged_browser: None,
|
|
105
157
|
})
|
|
106
158
|
}
|
|
107
159
|
|
|
@@ -297,11 +349,25 @@ impl std::fmt::Display for WebLocator {
|
|
|
297
349
|
/// tried in order: (1) exact match on the element's DIRECT text nodes (its
|
|
298
350
|
/// own text, so a sibling avatar's initials can never fuse with a label
|
|
299
351
|
/// into "ETE2E Test Runner's Team"); (2) exact match on the concatenated
|
|
300
|
-
/// subtree text (covers labels wrapped in spans); (3)
|
|
301
|
-
///
|
|
302
|
-
///
|
|
303
|
-
|
|
352
|
+
/// subtree text (covers labels wrapped in spans); (3) `<label>` association
|
|
353
|
+
/// by the label's exact text — both the wrapping form
|
|
354
|
+
/// `<label>Name: <input/></label>` and `<label for>`/`id` pairing; (4) and
|
|
355
|
+
/// (5) the own-text/subtree rungs as prefix matches (a leading match is
|
|
356
|
+
/// accepted when the accessible name carries trailing detail — catalog
|
|
357
|
+
/// cards, chips like `ID: …`); (6) label association as a prefix match
|
|
358
|
+
/// (so `Name` finds the field labelled `Name:`); (7) and (8) ASCII
|
|
359
|
+
/// case-insensitive fallbacks of the exact and prefix rungs — role names
|
|
360
|
+
/// are case-insensitive in Playwright, and real pages disagree with specs
|
|
361
|
+
/// about capitalization ("Close Account" vs "Close account"). A
|
|
362
|
+
/// case-sensitive match always wins over a case-insensitive one.
|
|
363
|
+
fn text_xpaths(text: &str) -> Vec<String> {
|
|
364
|
+
const UPPER: &str = "'ABCDEFGHIJKLMNOPQRSTUVWXYZ'";
|
|
365
|
+
const LOWER: &str = "'abcdefghijklmnopqrstuvwxyz'";
|
|
304
366
|
let lit = xpath_literal(text);
|
|
367
|
+
let lower_lit = xpath_literal(&text.to_ascii_lowercase());
|
|
368
|
+
let ci = |expr: &str| format!("translate({expr}, {UPPER}, {LOWER})={lower_lit}");
|
|
369
|
+
let ci_prefix =
|
|
370
|
+
|expr: &str| format!("starts-with(translate({expr}, {UPPER}, {LOWER}), {lower_lit})");
|
|
305
371
|
let build = |by_text: String, by_label: String, by_placeholder: String| {
|
|
306
372
|
format!(
|
|
307
373
|
"//*[self::button or self::a or self::summary or @role='button' or \
|
|
@@ -311,7 +377,20 @@ fn text_xpaths(text: &str) -> [String; 4] {
|
|
|
311
377
|
//textarea[{by_placeholder} or {by_label}]"
|
|
312
378
|
)
|
|
313
379
|
};
|
|
314
|
-
|
|
380
|
+
// Fields addressed by their <label>: the wrapping form associates by
|
|
381
|
+
// containment, the `for` form by id. XPath 1.0 node-set comparison
|
|
382
|
+
// makes `@id = //label[…]/@for` "any label whose for equals this id".
|
|
383
|
+
let by_label_assoc = |label_text: String| {
|
|
384
|
+
["input", "textarea", "select"]
|
|
385
|
+
.map(|tag| {
|
|
386
|
+
format!(
|
|
387
|
+
"//label[{label_text}]//{tag} | \
|
|
388
|
+
//{tag}[@id = //label[{label_text}]/@for]"
|
|
389
|
+
)
|
|
390
|
+
})
|
|
391
|
+
.join(" | ")
|
|
392
|
+
};
|
|
393
|
+
vec![
|
|
315
394
|
build(
|
|
316
395
|
format!("text()[normalize-space(.)={lit}]"),
|
|
317
396
|
format!("@aria-label={lit}"),
|
|
@@ -322,6 +401,7 @@ fn text_xpaths(text: &str) -> [String; 4] {
|
|
|
322
401
|
format!("@aria-label={lit}"),
|
|
323
402
|
format!("@placeholder={lit}"),
|
|
324
403
|
),
|
|
404
|
+
by_label_assoc(format!("normalize-space()={lit}")),
|
|
325
405
|
build(
|
|
326
406
|
format!("text()[starts-with(normalize-space(.), {lit})]"),
|
|
327
407
|
format!("starts-with(@aria-label, {lit})"),
|
|
@@ -332,6 +412,25 @@ fn text_xpaths(text: &str) -> [String; 4] {
|
|
|
332
412
|
format!("starts-with(@aria-label, {lit})"),
|
|
333
413
|
format!("starts-with(@placeholder, {lit})"),
|
|
334
414
|
),
|
|
415
|
+
by_label_assoc(format!("starts-with(normalize-space(), {lit})")),
|
|
416
|
+
format!(
|
|
417
|
+
"{} | {}",
|
|
418
|
+
build(
|
|
419
|
+
ci("normalize-space()"),
|
|
420
|
+
ci("@aria-label"),
|
|
421
|
+
ci("@placeholder"),
|
|
422
|
+
),
|
|
423
|
+
by_label_assoc(ci("normalize-space()")),
|
|
424
|
+
),
|
|
425
|
+
format!(
|
|
426
|
+
"{} | {}",
|
|
427
|
+
build(
|
|
428
|
+
ci_prefix("normalize-space()"),
|
|
429
|
+
ci_prefix("@aria-label"),
|
|
430
|
+
ci_prefix("@placeholder"),
|
|
431
|
+
),
|
|
432
|
+
by_label_assoc(ci_prefix("normalize-space()")),
|
|
433
|
+
),
|
|
335
434
|
]
|
|
336
435
|
}
|
|
337
436
|
|
|
@@ -368,6 +467,17 @@ impl AppDriver for WebAppDriver {
|
|
|
368
467
|
_window_name: &str,
|
|
369
468
|
_timeout: Duration,
|
|
370
469
|
) -> Result<(), DriverError> {
|
|
470
|
+
let staged_browser = self.staged_browser.take();
|
|
471
|
+
// Extra Chrome flags only apply at process start: swap in a
|
|
472
|
+
// PRIVATE browser for this flow (a plain tab on it is already
|
|
473
|
+
// isolated), paying its cold start instead of sharing.
|
|
474
|
+
if let Some(config) = &staged_browser {
|
|
475
|
+
if !config.args.is_empty() {
|
|
476
|
+
self.browser =
|
|
477
|
+
launch_browser(&config.args).map_err(|e| DriverError::Uia(e.to_string()))?;
|
|
478
|
+
self.context_id = None;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
371
481
|
// On the shared browser, open the tab INSIDE this flow's isolated
|
|
372
482
|
// context so its cookies/storage never touch another flow's.
|
|
373
483
|
let tab = match &self.context_id {
|
|
@@ -388,9 +498,146 @@ impl AppDriver for WebAppDriver {
|
|
|
388
498
|
None => self.browser.new_tab(),
|
|
389
499
|
}
|
|
390
500
|
.map_err(|e| web_err("opening tab", e))?;
|
|
501
|
+
// Viewport/UA emulation BEFORE navigation, so the app boots into
|
|
502
|
+
// the emulated device (responsive breakpoints, UA sniffing). NOT
|
|
503
|
+
// best-effort: a flow recorded mobile must never run desktop.
|
|
504
|
+
if let Some(config) = &staged_browser {
|
|
505
|
+
if let Some(vp) = &config.viewport {
|
|
506
|
+
tab.call_method(Emulation::SetDeviceMetricsOverride {
|
|
507
|
+
width: vp.width,
|
|
508
|
+
height: vp.height,
|
|
509
|
+
device_scale_factor: vp.device_scale_factor,
|
|
510
|
+
mobile: vp.mobile,
|
|
511
|
+
scale: None,
|
|
512
|
+
screen_width: None,
|
|
513
|
+
screen_height: None,
|
|
514
|
+
position_x: None,
|
|
515
|
+
position_y: None,
|
|
516
|
+
dont_set_visible_size: None,
|
|
517
|
+
screen_orientation: None,
|
|
518
|
+
viewport: None,
|
|
519
|
+
display_feature: None,
|
|
520
|
+
device_posture: None,
|
|
521
|
+
})
|
|
522
|
+
.map_err(|e| web_err("emulating viewport", e))?;
|
|
523
|
+
if vp.touch {
|
|
524
|
+
tab.call_method(Emulation::SetTouchEmulationEnabled {
|
|
525
|
+
enabled: true,
|
|
526
|
+
max_touch_points: Some(1),
|
|
527
|
+
})
|
|
528
|
+
.map_err(|e| web_err("emulating touch", e))?;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if let Some(ua) = &config.user_agent {
|
|
532
|
+
tab.set_user_agent(ua, None, None)
|
|
533
|
+
.map_err(|e| web_err("overriding user agent", e))?;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
391
536
|
if let Some(session) = self.staged_session.take() {
|
|
392
537
|
Self::apply_session(&tab, &session, command)?;
|
|
393
538
|
}
|
|
539
|
+
// Network mocks: install interception BEFORE navigation so even the
|
|
540
|
+
// first document's subresources are answerable. Unlike the console
|
|
541
|
+
// listener this is NOT best-effort — a mock that silently failed to
|
|
542
|
+
// install would change what the flow tests.
|
|
543
|
+
if !self.staged_mocks.is_empty() {
|
|
544
|
+
let mocks = std::mem::take(&mut self.staged_mocks);
|
|
545
|
+
tab.enable_fetch(None, None)
|
|
546
|
+
.map_err(|e| web_err("enabling network interception", e))?;
|
|
547
|
+
tab.enable_request_interception(Arc::new(
|
|
548
|
+
move |_transport: Arc<headless_chrome::browser::transport::Transport>,
|
|
549
|
+
_session: headless_chrome::browser::transport::SessionId,
|
|
550
|
+
event: headless_chrome::protocol::cdp::Fetch::events::RequestPausedEvent| {
|
|
551
|
+
use headless_chrome::browser::tab::RequestPausedDecision;
|
|
552
|
+
use headless_chrome::protocol::cdp::Fetch;
|
|
553
|
+
let url = &event.params.request.url;
|
|
554
|
+
let method = event.params.request.method.to_ascii_uppercase();
|
|
555
|
+
// Mocked responses must carry permissive CORS headers:
|
|
556
|
+
// the page's origin differs from the mocked host, and a
|
|
557
|
+
// fulfilled response is still subject to CORS — without
|
|
558
|
+
// them the fetch rejects and the mock looks dead.
|
|
559
|
+
let cors = |ct: Option<&str>| {
|
|
560
|
+
let mut headers = vec![
|
|
561
|
+
Fetch::HeaderEntry {
|
|
562
|
+
name: "access-control-allow-origin".into(),
|
|
563
|
+
value: "*".into(),
|
|
564
|
+
},
|
|
565
|
+
Fetch::HeaderEntry {
|
|
566
|
+
name: "access-control-allow-methods".into(),
|
|
567
|
+
value: "*".into(),
|
|
568
|
+
},
|
|
569
|
+
Fetch::HeaderEntry {
|
|
570
|
+
name: "access-control-allow-headers".into(),
|
|
571
|
+
value: "*".into(),
|
|
572
|
+
},
|
|
573
|
+
];
|
|
574
|
+
if let Some(ct) = ct {
|
|
575
|
+
headers.push(Fetch::HeaderEntry {
|
|
576
|
+
name: "content-type".into(),
|
|
577
|
+
value: ct.to_string(),
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
headers
|
|
581
|
+
};
|
|
582
|
+
let any_match = mocks.iter().any(|m| url.contains(&m.url_contains));
|
|
583
|
+
// CORS preflight for a mocked URL: answer it ourselves —
|
|
584
|
+
// the real host may not even exist.
|
|
585
|
+
if method == "OPTIONS" && any_match {
|
|
586
|
+
return RequestPausedDecision::Fulfill(Fetch::FulfillRequest {
|
|
587
|
+
request_id: event.params.request_id.clone(),
|
|
588
|
+
response_code: 204,
|
|
589
|
+
response_headers: Some(cors(None)),
|
|
590
|
+
binary_response_headers: None,
|
|
591
|
+
body: None,
|
|
592
|
+
response_phrase: None,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
let rule = mocks.iter().find(|m| {
|
|
596
|
+
url.contains(&m.url_contains)
|
|
597
|
+
&& m.method.as_ref().is_none_or(|want| *want == method)
|
|
598
|
+
});
|
|
599
|
+
match rule {
|
|
600
|
+
Some(m) => RequestPausedDecision::Fulfill(Fetch::FulfillRequest {
|
|
601
|
+
request_id: event.params.request_id.clone(),
|
|
602
|
+
response_code: u32::from(m.status),
|
|
603
|
+
response_headers: Some(cors(Some(&m.content_type))),
|
|
604
|
+
binary_response_headers: None,
|
|
605
|
+
body: Some(base64_encode(&m.body)),
|
|
606
|
+
response_phrase: None,
|
|
607
|
+
}),
|
|
608
|
+
None => RequestPausedDecision::Continue(None),
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
))
|
|
612
|
+
.map_err(|e| web_err("installing network mocks", e))?;
|
|
613
|
+
}
|
|
614
|
+
// Console tail: subscribe BEFORE navigation so boot-time errors are
|
|
615
|
+
// captured too. Best-effort — a page without console history still
|
|
616
|
+
// yields a DOM snapshot on failure.
|
|
617
|
+
if tab.enable_log().and_then(|t| t.enable_runtime()).is_ok() {
|
|
618
|
+
let buffer = self.console.clone();
|
|
619
|
+
let listener = move |event: &headless_chrome::protocol::cdp::types::Event| {
|
|
620
|
+
use headless_chrome::protocol::cdp::types::Event;
|
|
621
|
+
let line = match event {
|
|
622
|
+
Event::LogEntryAdded(e) => Some(format!(
|
|
623
|
+
"[{:?}] {}",
|
|
624
|
+
e.params.entry.level, e.params.entry.text
|
|
625
|
+
)),
|
|
626
|
+
Event::RuntimeExceptionThrown(e) => {
|
|
627
|
+
Some(format!("[exception] {}", e.params.exception_details.text))
|
|
628
|
+
}
|
|
629
|
+
_ => None,
|
|
630
|
+
};
|
|
631
|
+
if let Some(line) = line {
|
|
632
|
+
let mut buf = buffer.lock().unwrap_or_else(|e| e.into_inner());
|
|
633
|
+
if buf.len() >= CONSOLE_TAIL_CAP {
|
|
634
|
+
buf.pop_front();
|
|
635
|
+
}
|
|
636
|
+
buf.push_back(line);
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
tab.add_event_listener(Arc::new(listener)).ok();
|
|
640
|
+
}
|
|
394
641
|
tab.navigate_to(command)
|
|
395
642
|
.map_err(|e| web_err(&format!("navigating to {command}"), e))?;
|
|
396
643
|
tab.wait_until_navigated()
|
|
@@ -399,10 +646,47 @@ impl AppDriver for WebAppDriver {
|
|
|
399
646
|
Ok(())
|
|
400
647
|
}
|
|
401
648
|
|
|
649
|
+
fn debug_bundle(&mut self) -> Result<Option<flowproof_driver::DebugBundle>, DriverError> {
|
|
650
|
+
// Best-effort by contract: a half-captured bundle still beats none.
|
|
651
|
+
let dom_html = self
|
|
652
|
+
.tab()
|
|
653
|
+
.ok()
|
|
654
|
+
.and_then(|tab| {
|
|
655
|
+
tab.evaluate("document.documentElement.outerHTML", false)
|
|
656
|
+
.ok()
|
|
657
|
+
})
|
|
658
|
+
.and_then(|v| v.value)
|
|
659
|
+
.and_then(|v| v.as_str().map(str::to_string));
|
|
660
|
+
let console = self
|
|
661
|
+
.console
|
|
662
|
+
.lock()
|
|
663
|
+
.unwrap_or_else(|e| e.into_inner())
|
|
664
|
+
.iter()
|
|
665
|
+
.cloned()
|
|
666
|
+
.collect();
|
|
667
|
+
Ok(Some(flowproof_driver::DebugBundle { dom_html, console }))
|
|
668
|
+
}
|
|
669
|
+
|
|
402
670
|
fn surface_text(&mut self) -> Result<String, DriverError> {
|
|
671
|
+
// Visible text PLUS the accessible names of visible elements:
|
|
672
|
+
// icon-only buttons (a command palette, an account menu) exist on
|
|
673
|
+
// the page only as aria-labels, and `page shows` must see them.
|
|
403
674
|
let value = self
|
|
404
675
|
.tab()?
|
|
405
|
-
.evaluate(
|
|
676
|
+
.evaluate(
|
|
677
|
+
r#"(() => {
|
|
678
|
+
const text = document.body ? document.body.innerText : '';
|
|
679
|
+
const names = [];
|
|
680
|
+
for (const el of document.querySelectorAll('[aria-label]')) {
|
|
681
|
+
const r = el.getBoundingClientRect();
|
|
682
|
+
if (r.width > 0 && r.height > 0) {
|
|
683
|
+
names.push(el.getAttribute('aria-label'));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return names.length ? text + '\n' + names.join('\n') : text;
|
|
687
|
+
})()"#,
|
|
688
|
+
false,
|
|
689
|
+
)
|
|
406
690
|
.map_err(|e| web_err("reading page text", e))?;
|
|
407
691
|
Ok(value
|
|
408
692
|
.value
|
|
@@ -415,6 +699,19 @@ impl AppDriver for WebAppDriver {
|
|
|
415
699
|
Ok(())
|
|
416
700
|
}
|
|
417
701
|
|
|
702
|
+
fn stage_mocks(&mut self, rules: Vec<flowproof_driver::WebMock>) -> Result<(), DriverError> {
|
|
703
|
+
self.staged_mocks = rules;
|
|
704
|
+
Ok(())
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
fn stage_browser(
|
|
708
|
+
&mut self,
|
|
709
|
+
config: flowproof_driver::WebBrowserConfig,
|
|
710
|
+
) -> Result<(), DriverError> {
|
|
711
|
+
self.staged_browser = Some(config);
|
|
712
|
+
Ok(())
|
|
713
|
+
}
|
|
714
|
+
|
|
418
715
|
fn navigate(&mut self, url: &str) -> Result<(), DriverError> {
|
|
419
716
|
let tab = self.tab()?;
|
|
420
717
|
tab.navigate_to(url)
|
|
@@ -460,6 +757,31 @@ impl AppDriver for WebAppDriver {
|
|
|
460
757
|
Ok(value.value.and_then(|v| v.as_bool()).unwrap_or(true))
|
|
461
758
|
}
|
|
462
759
|
|
|
760
|
+
fn element_receives_events(
|
|
761
|
+
&mut self,
|
|
762
|
+
selector: &UiaSelector,
|
|
763
|
+
) -> Result<Option<bool>, DriverError> {
|
|
764
|
+
let locator = Self::locator(selector)?;
|
|
765
|
+
let value =
|
|
766
|
+
self.with_element(&locator, &format!("hit-testing [{selector}]"), |element| {
|
|
767
|
+
// Playwright's obscured check: does elementFromPoint at the
|
|
768
|
+
// element's center resolve to it (or a relative)? A toast or
|
|
769
|
+
// modal backdrop on top makes the click land elsewhere.
|
|
770
|
+
element.call_js_fn(
|
|
771
|
+
r#"function() {
|
|
772
|
+
const r = this.getBoundingClientRect();
|
|
773
|
+
if (r.width === 0 || r.height === 0) { return false; }
|
|
774
|
+
const t = document.elementFromPoint(
|
|
775
|
+
r.x + r.width / 2, r.y + r.height / 2);
|
|
776
|
+
return !!(t && (t === this || this.contains(t) || t.contains(this)));
|
|
777
|
+
}"#,
|
|
778
|
+
vec![],
|
|
779
|
+
false,
|
|
780
|
+
)
|
|
781
|
+
})?;
|
|
782
|
+
Ok(value.value.and_then(|v| v.as_bool()))
|
|
783
|
+
}
|
|
784
|
+
|
|
463
785
|
fn invoke(&mut self, selector: &UiaSelector) -> Result<(), DriverError> {
|
|
464
786
|
let locator = Self::locator(selector)?;
|
|
465
787
|
self.with_element(&locator, &format!("clicking [{selector}]"), |element| {
|
|
@@ -467,6 +789,64 @@ impl AppDriver for WebAppDriver {
|
|
|
467
789
|
})
|
|
468
790
|
}
|
|
469
791
|
|
|
792
|
+
fn set_files(&mut self, selector: &UiaSelector, paths: &[String]) -> Result<(), DriverError> {
|
|
793
|
+
// Absolute paths: Chrome resolves DOM.setFileInputFiles against ITS
|
|
794
|
+
// working directory, not ours — canonicalize (which also fails
|
|
795
|
+
// loudly on a missing file, before the step "passes" emptily).
|
|
796
|
+
let mut absolute = Vec::with_capacity(paths.len());
|
|
797
|
+
for path in paths {
|
|
798
|
+
let canonical = std::fs::canonicalize(path)
|
|
799
|
+
.map_err(|e| web_err(&format!("upload file '{path}'"), e))?;
|
|
800
|
+
absolute.push(canonical.to_string_lossy().into_owned());
|
|
801
|
+
}
|
|
802
|
+
let locator = Self::locator(selector)?;
|
|
803
|
+
self.with_element(
|
|
804
|
+
&locator,
|
|
805
|
+
&format!("setting files on [{selector}]"),
|
|
806
|
+
|element| {
|
|
807
|
+
let refs: Vec<&str> = absolute.iter().map(String::as_str).collect();
|
|
808
|
+
element.set_input_files(&refs).map(|_| ())
|
|
809
|
+
},
|
|
810
|
+
)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
fn context_click(&mut self, selector: &UiaSelector) -> Result<(), DriverError> {
|
|
814
|
+
let locator = Self::locator(selector)?;
|
|
815
|
+
let tab = self.tab()?.clone();
|
|
816
|
+
self.with_element(
|
|
817
|
+
&locator,
|
|
818
|
+
&format!("right-clicking [{selector}]"),
|
|
819
|
+
|element| {
|
|
820
|
+
element.scroll_into_view()?;
|
|
821
|
+
let point = element.get_midpoint()?;
|
|
822
|
+
for kind in [
|
|
823
|
+
Input::DispatchMouseEventTypeOption::MousePressed,
|
|
824
|
+
Input::DispatchMouseEventTypeOption::MouseReleased,
|
|
825
|
+
] {
|
|
826
|
+
tab.call_method(Input::DispatchMouseEvent {
|
|
827
|
+
Type: kind,
|
|
828
|
+
x: point.x,
|
|
829
|
+
y: point.y,
|
|
830
|
+
button: Some(Input::MouseButton::Right),
|
|
831
|
+
click_count: Some(1),
|
|
832
|
+
modifiers: None,
|
|
833
|
+
timestamp: None,
|
|
834
|
+
buttons: None,
|
|
835
|
+
force: None,
|
|
836
|
+
tangential_pressure: None,
|
|
837
|
+
tilt_x: None,
|
|
838
|
+
tilt_y: None,
|
|
839
|
+
twist: None,
|
|
840
|
+
delta_x: None,
|
|
841
|
+
delta_y: None,
|
|
842
|
+
pointer_Type: None,
|
|
843
|
+
})?;
|
|
844
|
+
}
|
|
845
|
+
Ok(())
|
|
846
|
+
},
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
|
|
470
850
|
fn read_text(&mut self, selector: &UiaSelector) -> Result<String, DriverError> {
|
|
471
851
|
let locator = Self::locator(selector)?;
|
|
472
852
|
// Inner text covers most elements; inputs expose their VALUE — the
|
|
@@ -680,3 +1060,49 @@ impl AppDriver for WebAppDriver {
|
|
|
680
1060
|
Ok(rects)
|
|
681
1061
|
}
|
|
682
1062
|
}
|
|
1063
|
+
|
|
1064
|
+
#[cfg(test)]
|
|
1065
|
+
mod tests {
|
|
1066
|
+
#[test]
|
|
1067
|
+
fn text_xpath_ladder_orders_exact_label_prefix_then_case_insensitive() {
|
|
1068
|
+
let rungs = super::text_xpaths("Close Account");
|
|
1069
|
+
assert_eq!(rungs.len(), 8);
|
|
1070
|
+
// Rung 1: exact own-text — unchanged from the original ladder.
|
|
1071
|
+
assert!(rungs[0].contains("text()[normalize-space(.)='Close Account']"));
|
|
1072
|
+
// Rung 3: label association — wrapping form and for/id pairing.
|
|
1073
|
+
assert!(rungs[2].contains("//label[normalize-space()='Close Account']//input"));
|
|
1074
|
+
assert!(rungs[2].contains("//input[@id = //label[normalize-space()='Close Account']/@for]"));
|
|
1075
|
+
assert!(rungs[2].contains("//select"));
|
|
1076
|
+
// Rung 6: label prefix — `Name` finds the field labelled `Name:`.
|
|
1077
|
+
assert!(rungs[5].contains("starts-with(normalize-space(), 'Close Account')"));
|
|
1078
|
+
// Rungs 7-8: case-insensitive fallbacks compare lowercased text.
|
|
1079
|
+
assert!(rungs[6].contains("translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='close account'"));
|
|
1080
|
+
assert!(rungs[6].contains("translate(@aria-label"));
|
|
1081
|
+
assert!(rungs[7].contains("starts-with(translate(normalize-space()"));
|
|
1082
|
+
// No case-sensitive rung mentions translate: exact always wins.
|
|
1083
|
+
for rung in &rungs[..6] {
|
|
1084
|
+
assert!(
|
|
1085
|
+
!rung.contains("translate("),
|
|
1086
|
+
"case-sensitive rung uses translate: {rung}"
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
#[test]
|
|
1092
|
+
fn base64_matches_the_standard_alphabet_and_padding() {
|
|
1093
|
+
// RFC 4648 vectors.
|
|
1094
|
+
for (input, want) in [
|
|
1095
|
+
(&b""[..], ""),
|
|
1096
|
+
(b"f", "Zg=="),
|
|
1097
|
+
(b"fo", "Zm8="),
|
|
1098
|
+
(b"foo", "Zm9v"),
|
|
1099
|
+
(b"foob", "Zm9vYg=="),
|
|
1100
|
+
(b"fooba", "Zm9vYmE="),
|
|
1101
|
+
(b"foobar", "Zm9vYmFy"),
|
|
1102
|
+
] {
|
|
1103
|
+
assert_eq!(super::base64_encode(input), want);
|
|
1104
|
+
}
|
|
1105
|
+
// Binary-safe (high bytes map into +/ territory).
|
|
1106
|
+
assert_eq!(super::base64_encode(&[0xfb, 0xff, 0xfe]), "+//+");
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
@@ -15,7 +15,9 @@ pub mod spec;
|
|
|
15
15
|
pub use clarify::{Clarification, ClarifyStage};
|
|
16
16
|
pub use heal::{heal, heal_with_author, HealError, HealReport};
|
|
17
17
|
pub use llm::{HttpModelClient, ModelClient};
|
|
18
|
-
pub use recorder::{
|
|
18
|
+
pub use recorder::{
|
|
19
|
+
record, record_incremental, record_with_author, Author, RecordError, RecordSummary,
|
|
20
|
+
};
|
|
19
21
|
pub use spec::{FlowSpec, SpecStep, SuiteManifest};
|
|
20
22
|
|
|
21
23
|
use std::env;
|