From e5bb53e32bee21920bc5fc44f5d092cfdeaa6aee Mon Sep 17 00:00:00 2001 From: Happy Mahlangu Date: Wed, 12 Aug 2026 20:44:45 +0200 Subject: [PATCH] test(sap): the simulator models a modal and a table control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The simulated SAP screen was one flat wnd[0] with every field hanging straight off it, so sap_sim_e2e could not fail when window scoping or tree nesting broke — only a person at the one real SAP machine would have noticed. It now serves two more shapes. A classic GuiTableControl carries its cells as children under SAP's real [column,row] ids, so the walk has to recurse past depth two and FindById has to survive brackets and a comma. Back opens a wnd[1] modal that sits in the session tree beside wnd[0] and leaves it again when dismissed; the two windows deliberately share no text, so which one a reader is reading has an answer. sap_sim_e2e drives both through the production COM engine, and takes a mutex around the simulator process: it publishes itself in the machine-wide ROT under SAPGUI, so two at once would be a coin flip. Closes #478 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 18 ++ crates/flowproof-cli/tests/sap_sim_e2e.rs | 200 +++++++++++++++--- .../tests/support/sap_simulator.py | 125 ++++++++++- 3 files changed, 307 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c32d0748..efee7fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ together). ## Unreleased +### Added + +- **The SAP test simulator models more than one screen shape.** Every + automated SAP check ran against the same flat VA01 window - one `wnd[0]`, + every field hanging straight off it. A fixture like that cannot fail. + Window scoping could break, nesting could break, and nothing short of a + person sitting at the one real SAP machine the team has would notice. + + The simulator now serves two more shapes. A classic `GuiTableControl` + carries its cells as CHILDREN, under SAP's real `[column,row]` ids, so the + tree walk has to recurse past depth two and `FindById` has to survive an id + with brackets and a comma in it. Back opens a `wnd[1]` modal that sits in + the session tree beside `wnd[0]` and leaves it again when dismissed - and + the two windows deliberately share no text, so which window a reader is + reading is a question with an answer. + + `sap_sim_e2e` drives both through the production COM engine. + ## 0.19.0 ### Added diff --git a/crates/flowproof-cli/tests/sap_sim_e2e.rs b/crates/flowproof-cli/tests/sap_sim_e2e.rs index 8e826bd7..7e005727 100644 --- a/crates/flowproof-cli/tests/sap_sim_e2e.rs +++ b/crates/flowproof-cli/tests/sap_sim_e2e.rs @@ -22,8 +22,11 @@ use std::io::BufRead; use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, MutexGuard}; +use flowproof_adapters::sap_com::SapAppDriver; use flowproof_agent::FlowSpec; +use flowproof_driver::{AppDriver, UiaSelector}; const SPEC: &str = "\ name: Create order @@ -37,6 +40,69 @@ steps: - assert: page shows Order 4711 saved "; +/// The simulator publishes itself in the MACHINE-WIDE Running Object Table +/// under `SAPGUI`, so two live at once would make which one the engine +/// attaches to a coin flip. Cargo runs the tests in a binary in parallel, so +/// a test holds this for as long as it owns a simulator process. +static SIMULATOR: Mutex<()> = Mutex::new(()); + +fn own_the_simulator() -> MutexGuard<'static, ()> { + SIMULATOR + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// This tier is opt-in: it needs Windows COM and pywin32. +fn e2e_enabled() -> bool { + if std::env::var("FLOWPROOF_E2E").as_deref() == Ok("1") { + return true; + } + eprintln!("skipping SAP simulator E2E: set FLOWPROOF_E2E=1 to run it"); + false +} + +/// Address an element by its scripting id — the native SAP selector rung. +fn by_id(id: &str) -> UiaSelector { + UiaSelector { + automation_id: Some(id.to_string()), + ..Default::default() + } +} + +/// Set an environment variable for the duration of a test, then put back +/// whatever was there. +struct EnvGuard { + key: &'static str, + previous: Option, +} + +impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } +} + +/// The credentials the simulator's login screen accepts. +fn staged_login() -> [EnvGuard; 3] { + [ + EnvGuard::set("SAP_USER", "SIMUSER"), + EnvGuard::set("SAP_PASSWORD", "SIMPASS"), + EnvGuard::set("SAP_CLIENT", "001"), + ] +} + /// Start the simulator and wait for its READY line. fn start_simulator() -> Child { let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -62,39 +128,16 @@ fn start_simulator() -> Child { #[test] fn real_com_engine_records_and_replays_against_the_simulator() { - if std::env::var("FLOWPROOF_E2E").as_deref() != Ok("1") { - eprintln!("skipping SAP simulator E2E: set FLOWPROOF_E2E=1 to run it"); + if !e2e_enabled() { return; } - - struct EnvGuard { - key: &'static str, - previous: Option, - } - impl EnvGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - } - impl Drop for EnvGuard { - fn drop(&mut self) { - if let Some(value) = &self.previous { - std::env::set_var(self.key, value); - } else { - std::env::remove_var(self.key); - } - } - } + let _serial = own_the_simulator(); // The simulator puts an unrelated logged-in connection first and the // requested SIM connection second, sitting at the login screen. This one // run therefore proves connection selection and environment-backed login // through the production COM implementation. - let _user = EnvGuard::set("SAP_USER", "SIMUSER"); - let _password = EnvGuard::set("SAP_PASSWORD", "SIMPASS"); - let _client = EnvGuard::set("SAP_CLIENT", "001"); + let _login = staged_login(); let dir = std::env::temp_dir().join("flowproof-sap-sim-e2e"); std::fs::create_dir_all(&dir).expect("temp dir"); @@ -105,8 +148,7 @@ fn real_com_engine_records_and_replays_against_the_simulator() { let spec = FlowSpec::parse(SPEC).expect("spec parses"); // Record through the PRODUCTION COM engine. - let mut driver = - flowproof_adapters::sap_com::SapAppDriver::new().expect("COM engine initializes"); + let mut driver = SapAppDriver::new().expect("COM engine initializes"); flowproof_agent::record(&spec, &mut driver, &trace_path) .expect("rules author the flow via real COM"); drop(driver); @@ -128,8 +170,7 @@ fn real_com_engine_records_and_replays_against_the_simulator() { // Replay through a fresh COM attachment. The simulator keeps its // state (the status bar text), which the surface assert re-reads. - let mut driver = - flowproof_adapters::sap_com::SapAppDriver::new().expect("COM engine initializes"); + let mut driver = SapAppDriver::new().expect("COM engine initializes"); let (report, _run_dir) = flowproof_replay::run_trace(&trace_path, &mut driver).expect("replay runs"); for step in &report.steps { @@ -147,3 +188,102 @@ fn real_com_engine_records_and_replays_against_the_simulator() { std::panic::resume_unwind(panic); } } + +/// The two screen shapes a flat single-window fixture can never fail on: a +/// classic `GuiTableControl` whose cells are NESTED children carrying SAP's +/// `[column,row]` ids, and a `wnd[1]` modal that opens over the main window +/// and hands it back when dismissed. +/// +/// Driven through the production COM engine, so what is proved is the real +/// thing: a tree walk that recurses past depth two, `FindById` surviving an +/// id with brackets and a comma in it, and a session whose SET OF WINDOWS +/// changes underneath a running flow. +/// +/// The modal is deliberately built so the two windows share no text — the +/// main window says "Create Standard Order", the popup says "Do you want to +/// save your data?". That is what lets a caller tell which window it is +/// reading, and it is the fixture #475's window scoping needs: once that +/// lands, asserting the background is NOT in `surface_text` while the popup +/// is open is one more line here. +#[test] +fn the_com_engine_drives_a_table_control_and_a_modal_window() { + if !e2e_enabled() { + return; + } + let _serial = own_the_simulator(); + let _login = staged_login(); + + let mut simulator = start_simulator(); + let result = std::panic::catch_unwind(|| { + let mut driver = SapAppDriver::new().expect("COM engine initializes"); + driver + .launch("SIM", "SAP", std::time::Duration::from_secs(60)) + .expect("attaches to the simulated SIM session"); + + // --- the classic table control --------------------------------- + const CELL: &str = "wnd[0]/usr/tblSAPMV45ATCTRL_U_ERF_AUFTRAG/ctxtVBAP-MATNR[0,1]"; + driver + .type_text(&by_id(CELL), "M-01") + .expect("a nested table cell takes input"); + assert_eq!( + driver.read_text(&by_id(CELL)).expect("cell reads back"), + "M-01", + "an id with brackets and a comma must survive the round trip" + ); + + let scene = driver.scene().expect("scene").expect("sap grounds a scene"); + assert!( + scene.contains(&format!("id:{CELL}")), + "cells inside a table are targets a model can be offered: {scene}" + ); + assert!( + !scene.contains("id:wnd[0]/usr/tblSAPMV45ATCTRL_U_ERF_AUFTRAG\""), + "the table CONTAINER is not something to act on: {scene}" + ); + let surface = driver.surface_text().expect("surface"); + assert!( + surface.contains("Order Quantity"), + "a label nested inside the table still reaches the surface: {surface}" + ); + + // --- the wnd[1] modal ------------------------------------------ + driver + .invoke(&by_id("wnd[0]/tbar[0]/btn[3]")) + .expect("Back opens the save prompt"); + let surface = driver.surface_text().expect("surface"); + assert!( + surface.contains("Do you want to save your data?"), + "a second window's text must be readable at all: {surface}" + ); + let scene = driver.scene().expect("scene").expect("json"); + assert!( + scene.contains("id:wnd[1]/usr/btnSPOP-OPTION2"), + "the popup's own buttons are what a flow can press: {scene}" + ); + + // Dismissing it removes the whole window from the session tree, so + // nothing behind a closed popup stays addressable. + driver + .invoke(&by_id("wnd[1]/usr/btnSPOP-OPTION2")) + .expect("'No' dismisses the prompt"); + assert!( + !driver + .element_exists(&by_id("wnd[1]/usr/btnSPOP-OPTION2")) + .expect("lookup succeeds"), + "a dismissed popup's controls must stop resolving" + ); + let surface = driver.surface_text().expect("surface"); + assert!( + !surface.contains("Do you want to save your data?"), + "the dismissed popup must leave the surface: {surface}" + ); + assert!( + surface.contains("Create Standard Order"), + "the main window is the surface again once the popup closes: {surface}" + ); + }); + let _ = simulator.kill(); + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} diff --git a/crates/flowproof-cli/tests/support/sap_simulator.py b/crates/flowproof-cli/tests/support/sap_simulator.py index 60e03065..1ce4eebf 100644 --- a/crates/flowproof-cli/tests/support/sap_simulator.py +++ b/crates/flowproof-cli/tests/support/sap_simulator.py @@ -22,6 +22,22 @@ "Order 4711 saved" to the status bar so recorded flows have an observable effect to assert. +It models three SAP screen SHAPES, not one, because a fixture with a single +flat window cannot fail when window scoping or nesting breaks: + + * the ordinary ``wnd[0]`` screen — flat fields under ``usr``; + * a ``GuiTableControl`` whose cells are CHILDREN of the table and carry + real SAP cell ids (``.../ctxtVBAP-MATNR[0,1]``), so the walk has to + recurse past depth two and FindById has to survive brackets and commas; + * a ``wnd[1]`` GuiModalWindow, opened by Back and closed by either of its + buttons. While it is open the session tree holds BOTH windows, exactly + as real SAP does, and the two windows carry deliberately disjoint text + so a caller can tell which one it is reading. + +The shapes are the extension points for the remaining SAP surfaces: an ALV +grid is another nested container under ``usr``, and an F4 search help is +another ``wnd[1]`` modal. Neither is modelled here yet. + Usage: python sap_simulator.py (prints READY when attachable; exits on its own after WATCHDOG_SECONDS as an orphan guard, or on Ctrl+C). """ @@ -36,6 +52,8 @@ # The ROT item-moniker name real SAP GUI publishes itself under. ROT_NAME = "SAPGUI" SESSION_PREFIX = "/app/con[0]/ses[0]/" +# The classic VA01 item table control, named as the real screen names it. +ITEM_TABLE = "wnd[0]/usr/tblSAPMV45ATCTRL_U_ERF_AUFTRAG" # Hard orphan guard only - generous enough that a slow CI runner's # record + replay never outlives it (the test kills the process when # it finishes; this exists for the case where it could not). @@ -177,16 +195,12 @@ class Screen: def __init__(self, user="FLOWPROOF", title="Create Standard Order", order_screen=True): self.vkeys = [] self.by_id = {} + self.modal = None self.session = Session(self, user=user, system="SIM" if order_screen else "OTHER") self.window = Window(self, "wnd[0]", "GuiMainWindow", "wnd[0]", text=title) self.session.add(self.window) self._register("wnd[0]", self.window) - - def field(rel_id, kind, name, tooltip, changeable=True, text=""): - component = Component(self, rel_id, kind, name, text, tooltip, changeable) - self.window.add(component) - self._register(rel_id, component) - return component + field = self.add_field field("wnd[0]/tbar[0]/okcd", "GuiOkCodeField", "okcd", "Command field") # Standard SAP login controls. The desired simulated connection starts @@ -224,17 +238,116 @@ def field(rel_id, kind, name, tooltip, changeable=True, text=""): changeable=False, text="Continue", ) + field( + "wnd[0]/tbar[0]/btn[3]", + "GuiButton", + "btn[3]", + "Back (F3)", + changeable=False, + ) + # The classic item table. Cells hang off the TABLE, not off the + # window, and their ids carry SAP's `[column,row]` suffix - the + # two things a flat fixture never made anyone get right. + table = field( + ITEM_TABLE, + "GuiTableControl", + "SAPMV45ATCTRL_U_ERF_AUFTRAG", + "Item overview", + changeable=False, + ) + for row in (0, 1): + field( + "%s/ctxtVBAP-MATNR[0,%d]" % (ITEM_TABLE, row), + "GuiCTextField", + "VBAP-MATNR", + "Material", + parent=table, + ) + field( + "%s/txtVBAP-KWMENG[1,%d]" % (ITEM_TABLE, row), + "GuiTextField", + "VBAP-KWMENG", + "Order Quantity", + parent=table, + ) self.sbar = field("wnd[0]/sbar", "GuiStatusbar", "sbar", "", changeable=False) + def add_field( + self, rel_id, kind, name, tooltip, changeable=True, text="", parent=None + ): + """Register one control. `parent` defaults to `wnd[0]`; passing a + container (a table, a modal) is what makes the tree deeper than + one level, which is the shape an ALV grid would reuse.""" + component = Component(self, rel_id, kind, name, text, tooltip, changeable) + (parent or self.window).add(component) + self._register(rel_id, component) + return component + def _register(self, rel_id, component): # FindById accepts both session-relative and absolute ids. wrapped = wrap(component) self.by_id[rel_id] = wrapped self.by_id[SESSION_PREFIX + rel_id] = wrapped + def open_modal(self): + """Put a `wnd[1]` popup over the main screen, the way SAP asks + whether to save on Back. + + The main window STAYS in the session tree while this is open - + that is the whole point. Its text ("Create Standard Order", the + field labels) is text the user cannot act on until the popup goes + away, so anything that reads the session flat will read it anyway. + """ + if self.modal is not None: + return + modal = Window( + self, "wnd[1]", "GuiModalWindow", "wnd[1]", text="Exit Processing" + ) + self.session.add(modal) + self._register("wnd[1]", modal) + self.modal = modal + self.add_field( + "wnd[1]/usr/txtMESSTXT1", + "GuiTextField", + "MESSTXT1", + "", + changeable=False, + text="Do you want to save your data?", + parent=modal, + ) + for suffix, caption in (("1", "Yes"), ("2", "No")): + self.add_field( + "wnd[1]/usr/btnSPOP-OPTION" + suffix, + "GuiButton", + "SPOP-OPTION" + suffix, + "", + changeable=False, + text=caption, + parent=modal, + ) + + def close_modal(self): + """Dismiss the popup: `wnd[1]` and everything under it leaves the + session tree, which is what makes the main window reachable again.""" + if self.modal is None: + return + self.session._children.pop() # wnd[1] is the last child added + for key in [k for k in self.by_id if "wnd[1]" in k]: + del self.by_id[key] + self.modal = None + def on_press(self, rel_id): if rel_id == "wnd[0]/tbar[1]/btn[8]": self.sbar.Text = "Order 4711 saved" + elif rel_id == "wnd[0]/tbar[0]/btn[3]": + self.open_modal() + elif rel_id.startswith("wnd[1]/usr/btnSPOP-OPTION"): + self.sbar.Text = ( + "Document 4711 saved" + if rel_id.endswith("1") + else "Processing was ended" + ) + self.close_modal() def on_vkey(self, vkey): if (