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
156 changes: 154 additions & 2 deletions crates/flowproof-adapters/src/sap_com.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ const INTERACTABLE_KINDS: &[&str] = &[
"GuiOkCodeField",
];

/// SAP keeps the main window in the session tree while a modal is open. Only
/// the highest numbered `wnd[n]` is the active surface; including background
/// windows lets assertions pass on text the user cannot currently act on.
fn window_index(id: &str) -> Option<u32> {
let rest = id.strip_prefix("wnd[")?;
rest.split_once(']')?.0.parse().ok()
}

fn active_window(elements: &[SapElement]) -> Option<u32> {
elements
.iter()
.filter_map(|element| window_index(&element.id))
.max()
}

/// `AppDriver` over a [`SapEngine`]. `E` is the COM bridge in production,
/// a fake in tests.
pub struct SapAppDriver<E: SapEngine> {
Expand Down Expand Up @@ -336,8 +351,19 @@ impl<E: SapEngine> AppDriver for SapAppDriver<E> {
// The desktop reading of "the surface": every visible text and
// tooltip in the session tree, top-down — same contract as UIA
// and the browser page text.
let elements = self.engine.walk()?;
let active_window = active_window(&elements);
let mut parts: Vec<String> = Vec::new();
for element in self.engine.walk()? {
for element in elements {
if active_window.is_some_and(|active| window_index(&element.id) != Some(active)) {
continue;
}
// SAP exposes the complete menu hierarchy even while every menu
// is closed. Those labels are not visible page text and must not
// satisfy a `page shows` assertion.
if element.kind == "GuiMenu" {
continue;
}
if !element.text.trim().is_empty() {
parts.push(element.text.trim().to_string());
}
Expand Down Expand Up @@ -399,8 +425,19 @@ impl<E: SapEngine> AppDriver for SapAppDriver<E> {
fn scene(&mut self) -> Result<Option<String>, DriverError> {
// The grounding set for LLM authoring: interactable elements with
// their `id:` TARGET TOKENS — same neutral contract as web/UIA.
// SAP exposes the entire menu hierarchy in the session tree even
// while those menus are closed. On a normal Easy Access window that
// hierarchy can exhaust the scene cap before the first field is
// reached. Preserve tree order within each group, but ground the
// ordinary screen controls before background menu items.
let mut elements = self.engine.walk()?;
let active_window = active_window(&elements);
if let Some(active) = active_window {
elements.retain(|element| window_index(&element.id) == Some(active));
}
elements.sort_by_key(|element| element.kind == "GuiMenu");
let mut entries: Vec<serde_json::Value> = Vec::new();
for element in self.engine.walk()? {
for element in elements {
if entries.len() >= 100 {
break;
}
Expand Down Expand Up @@ -1499,4 +1536,119 @@ mod tests {
// The status bar is not interactable — grounding must not offer it.
assert!(!scene.contains("sbar"));
}

#[test]
fn scene_prioritizes_screen_controls_over_the_closed_menu_hierarchy() {
let mut elements = Vec::new();
for index in 0..100 {
elements.push(SapElement {
id: format!("wnd[0]/mbar/menu[{index}]"),
kind: "GuiMenu".into(),
name: format!("menu[{index}]"),
text: format!("Menu {index}"),
..Default::default()
});
}
elements.push(SapElement {
id: "wnd[0]/usr/ctxtVBAK-VBELN".into(),
kind: "GuiCTextField".into(),
name: "VBAK-VBELN".into(),
tooltip: "Order".into(),
changeable: true,
..Default::default()
});
let mut d = SapAppDriver::with_engine(FakeEngine::with_elements(elements));

let scene = d.scene().expect("scene").expect("json");

assert!(
scene.contains("id:wnd[0]/usr/ctxtVBAK-VBELN"),
"a screen control must not be crowded out by closed menu items: {scene}"
);
}

#[test]
fn surface_text_excludes_labels_from_closed_menus() {
let mut d = SapAppDriver::with_engine(FakeEngine::with_elements(vec![
SapElement {
id: "wnd[0]".into(),
kind: "GuiMainWindow".into(),
text: "SAP Easy Access".into(),
..Default::default()
},
SapElement {
id: "wnd[0]/mbar/menu[4]/menu[2]/menu[3]".into(),
kind: "GuiMenu".into(),
text: "User Profile".into(),
tooltip: "User Profile".into(),
..Default::default()
},
]));

let surface = d.surface_text().expect("surface");

assert!(surface.contains("SAP Easy Access"));
assert!(
!surface.contains("User Profile"),
"a closed menu label is not visible page text: {surface}"
);
}

#[test]
fn surface_text_reads_the_modal_instead_of_the_background_window() {
let mut d = SapAppDriver::with_engine(FakeEngine::with_elements(vec![
SapElement {
id: "wnd[0]".into(),
kind: "GuiMainWindow".into(),
text: "SAP Easy Access".into(),
..Default::default()
},
SapElement {
id: "wnd[1]".into(),
kind: "GuiModalWindow".into(),
text: "Information".into(),
..Default::default()
},
SapElement {
id: "wnd[1]/usr/txtMESSTXT1".into(),
kind: "GuiTextField".into(),
text: "Cannot start transaction SMEN".into(),
..Default::default()
},
]));

let surface = d.surface_text().expect("surface");

assert!(surface.contains("Cannot start transaction SMEN"));
assert!(
!surface.contains("SAP Easy Access"),
"background text must not satisfy an assertion while a modal is active: {surface}"
);
}

#[test]
fn scene_offers_modal_controls_instead_of_background_controls() {
let mut d = SapAppDriver::with_engine(FakeEngine::with_elements(vec![
SapElement {
id: "wnd[0]/tbar[0]/btn[11]".into(),
kind: "GuiButton".into(),
text: "Save".into(),
..Default::default()
},
SapElement {
id: "wnd[1]/tbar[0]/btn[0]".into(),
kind: "GuiButton".into(),
text: "Continue".into(),
..Default::default()
},
]));

let scene = d.scene().expect("scene").expect("json");

assert!(scene.contains("id:wnd[1]/tbar[0]/btn[0]"));
assert!(
!scene.contains("id:wnd[0]/tbar[0]/btn[11]"),
"background controls must not be actionable through an active modal: {scene}"
);
}
}
91 changes: 91 additions & 0 deletions examples/sap/audit-sales-order.flow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# A deliberately long, read-only SAP GUI scenario for the reference TS3
# system. Order 314 is the same sample order used by view-order.flow.yaml.
#
# Safety boundary: VA03 display screens only. The flow enters an order number
# on the selection screen, opens Display Document Flow and Status Overview,
# selects read-only overview tabs, and returns to Easy Access. It never invokes
# Change, Create, Delete, Save, configuration, profile, or administration UI.
name: Audit sales order lifecycle
app: sap
steps:
Comment on lines +8 to +10
- Go to /nVA03
- Type 314 into the "id:wnd[0]/usr/ctxtVBAK-VBELN" field
- Press Enter
- assert: "page shows Display Standard Order 314: Overview"
- assert: the wnd[0]/usr/subSUBSCREEN_HEADER:SAPMV45A:4021/ctxtVBAK-VBELN field contains 314
- assert: the wnd[0]/usr/subSUBSCREEN_HEADER:SAPMV45A:4021/txtVBAK-NETWR field contains 2,200.00
- assert: the wnd[0]/usr/subSUBSCREEN_HEADER:SAPMV45A:4021/ctxtVBAK-WAERK field contains USD
- assert: the wnd[0]/usr/subSUBSCREEN_HEADER:SAPMV45A:4021/txtVBKD-BSTKD field contains 4500055022
- assert: page shows Sold-to Party
- assert: page shows Ship-to Party
- assert: the wnd[0]/usr/subSUBSCREEN_HEADER:SAPMV45A:4021/subPART-SUB:SAPMV45A:4701/ctxtKUAGV-KUNNR field contains USCU_S11
- assert: the wnd[0]/usr/subSUBSCREEN_HEADER:SAPMV45A:4021/subPART-SUB:SAPMV45A:4701/txtKUAGV-TXTPA field contains Company Bike World
- assert: the wnd[0]/usr/subSUBSCREEN_HEADER:SAPMV45A:4021/subPART-SUB:SAPMV45A:4701/ctxtKUWEV-KUNNR field contains USCU_S11
- assert: page shows Net Due in 30 Days
- assert: page shows Cost & Freight
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/ssubHEADER_FRAME:SAPMV45A:4440/ctxtRV45A-KETDAT field contains 03/12/2018
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/ssubHEADER_FRAME:SAPMV45A:4440/txtRV45A-BTGEW field contains 75
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/ssubHEADER_FRAME:SAPMV45A:4440/txtRV45A-GEWEI field contains KG
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/ssubHEADER_FRAME:SAPMV45A:4440/ctxtVBKD-ZTERM field contains NT30
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/ssubHEADER_FRAME:SAPMV45A:4440/ctxtVBKD-INCO1 field contains CFR
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/ssubHEADER_FRAME:SAPMV45A:4440/ctxtVBAK-VKORG field contains 1710
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/subSUBSCREEN_TC:SAPMV45A:4900/tblSAPMV45ATCTRL_U_ERF_AUFTRAG/txtVBAP-POSNR[0,0] field contains 10
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/subSUBSCREEN_TC:SAPMV45A:4900/tblSAPMV45ATCTRL_U_ERF_AUFTRAG/ctxtRV45A-MABNR[1,0] field contains MZ-FG-C900
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/subSUBSCREEN_TC:SAPMV45A:4900/tblSAPMV45ATCTRL_U_ERF_AUFTRAG/txtRV45A-KWMENG[3,0] field contains 5
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/subSUBSCREEN_TC:SAPMV45A:4900/tblSAPMV45ATCTRL_U_ERF_AUFTRAG/ctxtVBAP-VRKME[4,0] field contains PC
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/subSUBSCREEN_TC:SAPMV45A:4900/tblSAPMV45ATCTRL_U_ERF_AUFTRAG/txtVBAP-ARKTX[6,0] field contains C900 BIKE
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/subSUBSCREEN_TC:SAPMV45A:4900/tblSAPMV45ATCTRL_U_ERF_AUFTRAG/txtKOMV-KBETR[15,0] field contains 440.00
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/subSUBSCREEN_TC:SAPMV45A:4900/tblSAPMV45ATCTRL_U_ERF_AUFTRAG/ctxtVBAP-PRCTR[23,0] field contains US10_PLC
- assert: the wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01/ssubSUBSCREEN_BODY:SAPMV45A:4400/subSUBSCREEN_TC:SAPMV45A:4900/tblSAPMV45ATCTRL_U_ERF_AUFTRAG/txtVBSTT-GBSTA_BEZ[24,0] field contains Completed
- Press the "id:wnd[0]/tbar[1]/btn[5]" button
- assert: page shows Document Flow
- assert: page shows Display Document
- assert: page shows Service Documents
- Press F3
- assert: "page shows Display Standard Order 314: Overview"
- Press the "id:wnd[0]/tbar[1]/btn[36]" button
- assert: "page shows Sales order: Status Overview"
- assert: page shows Standard Order
- assert: page shows 314
- assert: page shows USCU_S11
- assert: page shows Bike World
- assert: page shows Current Hdr Status
- assert: page shows Current Item Status
- assert: page shows Completed
- assert: page shows Nothing Rejected
- Press F3
- assert: "page shows Display Standard Order 314: Overview"
- Click "id:wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\02"
- assert: page shows Requested Deliv.Date
- assert: page shows Delivering Plant
- assert: page shows MZ-FG-C900
- assert: page shows C900 BIKE
- assert: page shows 440.00
- assert: page shows US10_PLC
- assert: page shows Completed
- Click "id:wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\06"
- assert: page shows Overall Status
- assert: page shows Delivery Status
- assert: page shows Fully Delivered
- assert: page shows Not Blocked
- assert: page shows Total Weight
- assert: page shows 75
- assert: page shows KG
- Click "id:wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\08"
- assert: page shows Reason for rejection
- assert: page shows Order Reason
- assert: page shows MZ-FG-C900
- assert: page shows C900 BIKE
- assert: page shows 2,200.00
- Click "id:wnd[0]/usr/tabsTAXI_TABSTRIP_OVERVIEW/tabpT\01"
- assert: page shows Pyt Terms
- assert: page shows Net Due in 30 Days
- assert: page shows Incoterms
- assert: page shows CFR
- assert: page shows Cost & Freight
- assert: page shows Dom. Sales Org US, Direct Sales, Product Division 00
- Press F3
- assert: page shows Display Sales Documents
- assert: the wnd[0]/usr/ctxtVBAK-VBELN field contains 314
- Press F3
- assert: page shows SAP Easy Access
Loading
Loading