Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/flowproof-agent/src/author.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ fn action_targets(action: &ResolvedAction) -> Vec<&Target> {
| ResolvedAction::Scroll { target: None, .. }
| ResolvedAction::AssertSql { .. }
| ResolvedAction::AssertApi { .. }
| ResolvedAction::AssertSpreadsheet { .. }
| ResolvedAction::CaptureDownload { .. } => vec![],
}
}
Expand Down
64 changes: 64 additions & 0 deletions crates/flowproof-agent/src/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,36 @@ fn step_for(id: usize, intent: &str, app: &str, action: &ResolvedAction) -> Step
}),
)
}
ResolvedAction::AssertSpreadsheet {
path,
sheet,
at,
column,
row_contains,
equals,
contains,
timeout_ms,
} => {
let mut expect = serde_json::Map::new();
if let Some(value) = equals {
expect.insert("equals".into(), value.as_str().into());
}
if let Some(needle) = contains {
expect.insert("contains".into(), needle.as_str().into());
}
expect.insert("timeout_ms".into(), (*timeout_ms).into());
(
Vec::new(),
Action::Assert(Assertion::Spreadsheet {
path: path.clone(),
sheet: sheet.clone(),
at: at.clone(),
column: column.clone(),
row_contains: row_contains.clone(),
expect: Some(serde_json::Value::Object(expect)),
}),
)
}
};
if app == "vision" {
stamp_vision_relation(&mut selectors, action);
Expand Down Expand Up @@ -1150,6 +1180,7 @@ fn action_selector(action: &ResolvedAction) -> Option<UiaSelector> {
| ResolvedAction::Reload
| ResolvedAction::AssertSql { .. }
| ResolvedAction::AssertApi { .. }
| ResolvedAction::AssertSpreadsheet { .. }
| ResolvedAction::AssertScreenshot { .. }
| ResolvedAction::CaptureDownload { .. } => return None,
};
Expand Down Expand Up @@ -1705,6 +1736,7 @@ impl ReuseCursor {
| ResolvedAction::AssertCaptured { .. }
| ResolvedAction::AssertSql { .. }
| ResolvedAction::AssertApi { .. }
| ResolvedAction::AssertSpreadsheet { .. }
);
if !is_assert {
if let Some(selector) = action_selector(&action) {
Expand Down Expand Up @@ -3466,6 +3498,38 @@ pub fn record_with_reuse_and_options<D: AppDriver, C: ModelClient>(
}
}
}
ResolvedAction::AssertSpreadsheet {
path,
sheet,
at,
column,
row_contains,
equals,
contains,
timeout_ms,
} => {
// The trace stores references, never values: a
// `${captured.x}` (the downloaded export's own path is
// the common case) resolves from this run's captures,
// then a `${VAR}` from the environment — the same
// two-step ladder `TypeText` resolves with.
let substituted = flowproof_trace::captures::substitute(path, &captures)
.map_err(|reason| RecordError::AssertMismatch {
intent: spec_step.intent().to_string(),
expected: "a remembered capture in the spreadsheet path".to_string(),
actual: reason,
})?;
let probe = flowproof_driver::oob::OobProbe::Spreadsheet {
path: flowproof_trace::secret::resolve_refs(&substituted)?,
sheet: sheet.clone(),
at: at.clone(),
column: column.clone(),
row_contains: row_contains.clone(),
equals: equals.clone(),
contains: contains.clone(),
};
poll_oob(&probe, *timeout_ms, &spec_step.intent())?;
}
ResolvedAction::PressKey { key, modifiers } => {
let mods: Vec<flowproof_driver::KeyMod> =
modifiers.iter().map(driver_key_mod).collect();
Expand Down
188 changes: 188 additions & 0 deletions crates/flowproof-agent/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,17 @@ pub enum ResolvedAction {
retry: Option<bool>,
timeout_ms: u64,
},
/// Out-of-band spreadsheet assertion: the exported file on disk.
AssertSpreadsheet {
path: String,
sheet: Option<String>,
at: Option<String>,
column: Option<String>,
row_contains: Option<String>,
equals: Option<String>,
contains: Option<String>,
timeout_ms: u64,
},
}

/// How an [`ResolvedAction::AssertText`] expectation compares against the
Expand Down Expand Up @@ -1006,6 +1017,54 @@ fn resolve_step_inner(app: &str, step: &SpecStep) -> Result<Vec<ResolvedAction>,
.map_or(ASSERT_TIMEOUT_MS, |s| s * 1000),
}]);
}
SpecStep::AssertSpreadsheet { assert_spreadsheet } => {
// Exactly one cell address: `at` alone, or `column` and
// `row_contains` together — mirrors `assert_api`'s cross-field
// rules (a config mistake fails here, before anything records,
// not with a probe-time surprise).
let has_at = assert_spreadsheet.at.is_some();
let has_anchor =
assert_spreadsheet.column.is_some() || assert_spreadsheet.row_contains.is_some();
if has_at && has_anchor {
return Err(unresolvable(
&assert_spreadsheet.path,
"assert_spreadsheet sets `at` OR `column`+`row_contains`, not both",
));
}
if !has_at && !has_anchor {
return Err(unresolvable(
&assert_spreadsheet.path,
"assert_spreadsheet needs either `at` or both `column` and `row_contains`",
));
}
if has_anchor
&& (assert_spreadsheet.column.is_none()
|| assert_spreadsheet.row_contains.is_none())
{
return Err(unresolvable(
&assert_spreadsheet.path,
"assert_spreadsheet's `column` and `row_contains` must be set together",
));
}
if assert_spreadsheet.equals.is_some() && assert_spreadsheet.contains.is_some() {
return Err(unresolvable(
&assert_spreadsheet.path,
"assert_spreadsheet sets at most one of equals/contains",
));
}
return Ok(vec![ResolvedAction::AssertSpreadsheet {
path: assert_spreadsheet.path.clone(),
sheet: assert_spreadsheet.sheet.clone(),
at: assert_spreadsheet.at.clone(),
column: assert_spreadsheet.column.clone(),
row_contains: assert_spreadsheet.row_contains.clone(),
equals: assert_spreadsheet.equals.clone(),
contains: assert_spreadsheet.contains.clone(),
timeout_ms: assert_spreadsheet
.timeout_seconds
.map_or(ASSERT_TIMEOUT_MS, |s| s * 1000),
}]);
}
// A whole-run control assertion, not a per-step driver action: the
// record-time store-guard and the replay scan enforce it out of band
// against the captured corpus. It performs nothing on the UI, so it
Expand Down Expand Up @@ -6642,6 +6701,135 @@ mod dialog_suffix_tests {
}
}

#[cfg(test)]
mod spreadsheet_assertion_tests {
use super::*;

#[test]
fn assert_spreadsheet_by_at_threads_through() {
let spec: crate::spec::SpreadsheetAssertSpec = serde_yaml::from_str(
"path: ${captured.pir_export}\nsheet: Sheet1\nat: B2\nequals: \"12.50\"\n",
)
.expect("spec parses");
let step = SpecStep::AssertSpreadsheet {
assert_spreadsheet: spec,
};
let actions = resolve_step("web", &step).expect("resolves");
let ResolvedAction::AssertSpreadsheet {
path,
sheet,
at,
column,
row_contains,
equals,
..
} = &actions[0]
else {
panic!("expected AssertSpreadsheet");
};
// A raw ref passes through untouched — resolution is probe-time only.
assert_eq!(path, "${captured.pir_export}");
assert_eq!(sheet.as_deref(), Some("Sheet1"));
assert_eq!(at.as_deref(), Some("B2"));
assert_eq!(column, &None);
assert_eq!(row_contains, &None);
assert_eq!(equals.as_deref(), Some("12.50"));
}

#[test]
fn assert_spreadsheet_by_column_and_row_contains_threads_through() {
let spec: crate::spec::SpreadsheetAssertSpec = serde_yaml::from_str(
"path: pir.xlsx\ncolumn: Net Price\nrow_contains: \"100-100\"\ncontains: \"12\"\n",
)
.expect("spec parses");
let actions = resolve_step(
"web",
&SpecStep::AssertSpreadsheet {
assert_spreadsheet: spec,
},
)
.expect("resolves");
let ResolvedAction::AssertSpreadsheet {
at,
column,
row_contains,
contains,
..
} = &actions[0]
else {
panic!("expected AssertSpreadsheet");
};
assert_eq!(at, &None);
assert_eq!(column.as_deref(), Some("Net Price"));
assert_eq!(row_contains.as_deref(), Some("100-100"));
assert_eq!(contains.as_deref(), Some("12"));
}

#[test]
fn assert_spreadsheet_neither_at_nor_anchor_is_a_spec_error() {
let spec: crate::spec::SpreadsheetAssertSpec =
serde_yaml::from_str("path: pir.xlsx\n").expect("parses");
let err = resolve_step(
"web",
&SpecStep::AssertSpreadsheet {
assert_spreadsheet: spec,
},
)
.expect_err("no cell address at all must fail early");
assert!(
err.to_string().contains("`at`") && err.to_string().contains("row_contains"),
"names both forms: {err}"
);
}

#[test]
fn assert_spreadsheet_both_at_and_anchor_is_a_spec_error() {
let spec: crate::spec::SpreadsheetAssertSpec =
serde_yaml::from_str("path: pir.xlsx\nat: B2\ncolumn: Net Price\nrow_contains: x\n")
.expect("parses");
let err = resolve_step(
"web",
&SpecStep::AssertSpreadsheet {
assert_spreadsheet: spec,
},
)
.expect_err("at and column/row_contains together is a contradiction");
assert!(err.to_string().contains("not both"), "{err}");
}

#[test]
fn assert_spreadsheet_column_without_row_contains_is_a_spec_error() {
let spec: crate::spec::SpreadsheetAssertSpec =
serde_yaml::from_str("path: pir.xlsx\ncolumn: Net Price\n").expect("parses");
let err = resolve_step(
"web",
&SpecStep::AssertSpreadsheet {
assert_spreadsheet: spec,
},
)
.expect_err("a header alone cannot name a row");
assert!(err.to_string().contains("together"), "{err}");
}

#[test]
fn assert_spreadsheet_both_equals_and_contains_is_a_spec_error() {
let spec: crate::spec::SpreadsheetAssertSpec =
serde_yaml::from_str("path: pir.xlsx\nat: A1\nequals: x\ncontains: y\n")
.expect("parses");
let err = resolve_step(
"web",
&SpecStep::AssertSpreadsheet {
assert_spreadsheet: spec,
},
)
.expect_err("one question, two spellings");
assert!(
err.to_string().contains("at most one of equals/contains"),
"{err}"
);
}
}

#[cfg(test)]
mod capture_download_tests {
use super::*;
Expand Down
Loading
Loading