Test cases that span technologies
How multi-surface flows record and replay across apps and surfaces with exports: and apps: + in: blocks.
Status: Phases 1 and 2 are shipped.
exports:chains single-surface flows through a suite (authoring.md), and multi-surface flows record AND replay (authoring.md):apps:+in:blocks, one surface active at a time, captures crossing blocks, per-step surface attribution, replay from the trace alone with zero LLM calls. Per-surfacebrowser:andwindow:are shipped — vision surfaces included —assert_screenshotbaselines are surface-qualified (<name>@<surface>.png), andhealruns on the same surface registry recording uses. Nothing from Phase 2 remains open. The one multi-surface shape still refused is a surface that names alogin:— nothing stages a surface's credentials yet (below). Phase 3 remains a proposal.
The problem
A real end-to-end test case often crosses surfaces: SAP GUI creates the
order, the web portal must list it, and perhaps an agent or a native
Windows app acts on it after that. A flow drives exactly one surface — one
app:, one driver, chosen at launch — and that is a fact worth keeping
(one driver per flow is what makes record and replay simple to reason
about). But the test case must not be capped at one technology, or the
gap gets filled by hand-written harness scripts between flowproof
invocations: exactly the glue flowproof exists to replace.
The enabler is already in the architecture: every adapter implements the
one AppDriver trait, and the step grammar is adapter-agnostic — a step
does not know which surface executes it. Spanning technologies is a
dispatch and attribution problem, not a grammar redesign.
Phase 1 — the suite is the test case (exports:, shipped)
Suites already mix app kinds (one directory can hold sap, web, api
and agent flows), order: pins sequencing, env_from mints shared test
data, and out-of-band assertions (assert_api, assert_sql,
assert_screenshot) work in any flow regardless of app:. The missing
piece was carrying a value a flow learned to the flows after it —
captures are flow-scoped, and suite env is fixed before any flow runs.
exports: closes that gap: a flow resolves ENV_NAME: template pairs
from its own captures when its last step passes, and the pairs become
environment variables for the suite's remaining flows. The downstream flow
references them as ordinary ${VAR}s, so its trace stores only the
reference and the handoff happens fresh on every replay. Nothing is
persisted; an unresolvable export fails the flow that owns the captures; a
failed flow exports nothing.
This covers the dominant shape of cross-technology test case — do in system A, prove in system B — with one driver per flow and no new trace format. When a case genuinely ping-pongs between surfaces mid-flow, Phase 2 is the answer.
Phase 2 — multi-surface flows (shipped)
One flow file, one trace, several named surfaces, exactly one active at a time:
name: Order across GUI and portal
apps:
gui: { app: sap, connection: ${SAP_CONNECTION} }
portal: { app: web, url: ${PORTAL_URL} }
steps:
- in: gui
steps:
- Go to /nVA01
# ... create and save the order, then read the number from its own
# field rather than out of the status bar's sentence ...
- Go to /nVA02
- Remember the "id:wnd[0]/usr/ctxtVBAK-VBELN" as order
- in: portal
steps:
- Type ${captured.order} into the "Search" field
- assert: page shows ${captured.order}A sap surface takes login: too, with the same shape and the same rules as
on a single-surface flow — which is what a same-system, two-user case
wants, one flow with a clerk and an approver surface:
apps:
clerk: { app: sap, connection: TS3, login: { user: obeva, password: ${CLERK_PW} } }
approver: { app: sap, connection: TS3, login: { user: approver, password: ${APPROVER_PW} } }That parses and validates today. It does not RUN today: nothing stages a
surface's credentials yet, so record and heal refuse a surface that names
a login: — launching anyway would drive whatever SAP session was already
open, as whoever opened it, which is the exact confusion login: exists to
prevent. Until it lands, the same case is a suite of single-surface flows with
one login: each, chained with exports: — Phase 1, which is shipped.
flowproof config sap/fiori (plans/001-credential-config.md) is a
different layer and does not close this gap: it seeds process env vars as
a personal-machine default, one value per variable, so it cannot hold the
clerk/approver case's two simultaneous SAP_USERs any more than a plain
shell export could. What staging a surface's login: needs is a way to hand
each surface its own credential pair independent of the process environment
— worth designing alongside whichever of the two lands second, so the second
one's interface doesn't get built twice.
Design decisions, each with its reason:
- Explicit
in:blocks, not per-step surface prefixes. The prose grammar stays untouched inside each block, transitions are visible in the spec, the trace, and failure output, and a reviewer can see the seams. - Sequential activation; exactly one active surface. Not merely simpler — correct on Windows. SAP GUI scripting, UIA and vision all inject real input into the foreground window; two live input-injecting drivers would fight over focus. Only CDP is out-of-band. A block boundary parks the current driver and foregrounds the next; interleaving is refused at parse time with the reason named.
- One capture namespace across blocks. That is the point of the
feature:
${captured.order}minted in theguiblock types into theportalblock. Capture semantics are unchanged; only their reach grows. - Lazy launch, kept alive. A named app launches at its first block and
stays up, so returning to
guiin a later block resumes the same session. - A
windows-mapping surface'scommand/window_titlemay reference a capture minted by an earlier block —${captured.download_path}types into a launch command the same way it types into a field, resolved at the surface's actual activation rather than before any step has run (a value that does not exist yet cannot resolve). This is what lets a block that downloads a file hand its path to a later block that opens it in a different application:Wait until the download completes as export, then anexcel:surface launched withEXCEL.EXE ${captured.export}. An unresolved capture at activation time (the minting block never ran, or ran on the wrong surface) fails the run closed, naming what was missing — never a launch against the literal${captured.x}text.web/sap/visionsurfaces resolve the same way but arrive already fully resolved in practice, since nothing downstream of Fiori/SAP GUI login needs a value a flow only learns mid-run. - Backward compatible. A bare
app: webremains the single-surface flow it always was — the multi-surface form is additive vocabulary.
Trace format: the header's single app grows an additive optional apps
map, and step records gain an optional surface attribution — additive
optional fields, which trace v1 permits without a version bump. The
ratchet still applies: docs/trace-format.md and
crates/flowproof-trace/schema/ move in the same commit as the format
change. Replay routes each recorded step to its recorded surface through a
driver registry holding one driver per named app.
Open questions Phase 2 must answer before it ships:
window:geometry andbrowser:config become per-surface.flowproof healheals multi-surface flows: healing is re-record-plus- diff, so the registry does the surface work, and a step that moved between surfaces diffs as asurfacechange. Shipped.assert_screenshotbaselines carry the surface in their identity (<name>@<surface>.png), so aguibaseline can never be compared against aportalframe. Shipped.
Delivery is several small PRs (the ratchets refuse large ones): spec parsing and refusals first, trace format with schema and docs second, the record path third, the replay path fourth — each with its tests.
Phase 3 — agent segments in a multi-surface flow (design, not yet code)
Status: proposal. Everything below is design for discussion; the parser accepts none of it. Today an agent flow chains through a suite via
exports:— its spec consumes${ORDER_NO}like any other flow — which covers "UI produces, agent consumes" without any of this.
What Phase 2 cannot express: an agent acting IN THE MIDDLE of a UI flow, on values captured moments earlier, with the flow continuing on what the agent did. The shape:
name: Order triage across surfaces
apps:
gui: {app: sap, connection: "${SAP_CONNECTION}"}
assistant:
app: agent
agent: {command: "python support_agent.py"}
tools: [...]
steps:
- in: gui
steps:
# ... create and save the order, then read the number from its own
# field rather than out of the status bar's sentence ...
- Go to /nVA02
- Remember the "id:wnd[0]/usr/ctxtVBAK-VBELN" as order
- in: assistant
steps:
- prompt: Investigate order ${captured.order} and set its priority.
- assert_tool_call: set_priority with order ${captured.order}
- in: gui
steps:
- Press F5
- assert: page shows Priority updatedThe four design decisions
1. An agent surface is a surface entry, not a flow field. The
agent:/tools:/mcp:/strict: blocks (today refused on multi-surface
flows) move INTO the surface entry, exactly as url: and browser: did.
The agent kind-refusal lifts only when the entry carries its agent:
block; the surface's steps are the agent step forms (prompt:,
assert_tool_call:, assert_no_tool_call:, assert_no_egress) and
NOTHING else — a UI step inside an agent block is a parse error naming
the two grammars.
2. The cassette is a sidecar, referenced from one step. An agent
trace is a single JSON document; a multi-surface trace is JSON-lines. Do
not merge the shapes: the agent block records as ONE step in the step log
— action: {type: "agent_run", params: {cassette: "<stem>.cassettes/ assistant-1.json", sha256: …}} — whose cassette lives in a sibling
directory, exactly the relocatable-bundle pattern baselines already use.
The step log stays diffable line-by-line; the cassette stays reviewable
as the document it is; the trace directory stays self-contained. An
engine predating agent_run fails loudly on the unknown action type.
3. Captures cross INTO the seam; what crosses back is named.
prompt: text interpolates ${captured.<name>} — resolved at execution
on record and every replay, stored raw, the discipline everything else
follows. The reverse direction gets ONE new step form:
- remember_answer: {matching: "/ticket (\\d+)/", as: ticket}reading the agent's FINAL answer (a value the cassette already stores),
so later UI blocks can type ${captured.ticket}. Tool results and
intermediate turns are deliberately not capturable in v1 — the final
answer is the agent's contract; mining its internals would couple flows
to trajectory details healing is allowed to change.
4. Replay stays zero-LLM by construction; containment scopes to the
block. Replaying an agent_run step replays its cassette — the same
executor app: agent flows use, fed from the sidecar — while UI steps
replay as today. Egress containment (where enforced) arms when the block
starts and disarms when it ends; assert_no_egress and the tool-call
asserts judge THAT block's cassette only. assert_no_secret_leak
remains refused on multi-surface flows until its corpus question is
answered for mixed lanes.
Slices, when this leaves proposal
- Vocabulary: agent surface entries + agent-step grammar inside their
blocks +
remember_answer(parse + validation + refusals, engine refuses at record like Phase 2's slice 1 did). - Trace: the
agent_runaction + cassette sidecar in schema and docs, same commit. - Record: the recorder runs the agent segment through the existing agent runner, writes the sidecar, stamps the step.
- Replay: cassette replay behind the step, captures rejoined.
The arbitrary-Windows-app case needs no phase of its own: app: {command, window_title} is one more entry in the Phase 2 apps: map.