SLICE-1.6: FlaUI-Driven Measurement Capture
- Status: Completed (2026-04-27)
- Date: 2026-04-27
- Depends on: Evolution Roadmap, SLICE-006: Observability Baseline, SLICE-1.1: Multi-Tag Telemetry, SLICE-1.2: Real Frame Payloads
Goal
Replace the retired IScenario / Capture-Measurements.ps1 rig (SLICE-1.5, SLICE-1.5.1) with a UI-Automation-driven approach using FlaUI. The new rig launches the real WPF main window, drives operator actions through actual button clicks (not ICommand.Execute(null)), and runs dotnet-counters against the running process for metric collection. After this slice, the existing scenarios — Demo Baseline, Multi-Tag Soak — are reproducible end-to-end through the same code path a real operator uses, and the deferred slice-1-2-real-frame-payloads row is captured.
Why This Slice
The retired rig (SLICE-1.5) drove view-model ICommand instances directly through an IOperatorCommands adapter. That bypass was a known design tradeoff documented in the SLICE-1.5 spec, but two things made it worse than expected in practice:
- It missed the actual XAML binding layer. The captures produced row data that didn't exercise the dispatcher, the
{Binding}machinery, or theCanExecuteChangedplumbing — exactly the surface where regressions are most likely. A "passing" automated row could coexist with a broken UI binding. - It accumulated bypass-only baggage. Each new scenario needed an
IScenarioclass, anIOperatorCommandsmapping, dispatcher-aware state-wait helpers — none of which mirrored what an operator actually does. The infrastructure outweighed what it tested.
FlaUI (FlaUI.Core + FlaUI.UIA3) wraps the Microsoft UI Automation API. It finds elements by AutomationId / Name / ControlType, clicks them, reads their text. It runs against a foreground WPF window — the window has to actually render, the dispatcher has to pump messages, the bindings have to resolve. That's exactly the realism the retired rig sacrificed.
The cost: FlaUI captures are slower (real button clicks include real animation/dispatcher latency), can't run headless or in CI without RDP, and require AutomationId discipline on the XAML side. Those costs are real but not blocking — Phase 1's measurement budget already assumes a developer machine with a visible window.
Requirements Coverage
- 04. UI and Technical Requirements: captures must exercise the full operator-to-app code path, including bindings
- 07. AI Delivery Constraints and Roadmap: each phase ships a measurable before-and-after — this slice is the new vehicle for producing those rows from SLICE-1.2 onward
- 04. UI and Technical Requirements (regression-protection): UI-binding regressions must be catchable by the same rig that produces measurements
In Scope
- a new test project
tests/InspectionPrototype.AcceptanceTests(.NET 10, Windows-only via<TargetFramework>net10.0-windows</TargetFramework>) referencing:FlaUI.CoreandFlaUI.UIA3from NuGet (centralized inDirectory.Packages.props)- the same xUnit version as
InspectionPrototype.Tests - no reference to any
InspectionPrototype.*source project — the rig is black-box; it talks to the running app over UI Automation only
- a small driver abstraction:
IUiDriverwith the methods scenarios actually need:Task ClickByAutomationIdAsync(string id, TimeSpan? timeout = null),Task<string> ReadTextByAutomationIdAsync(string id),Task<bool> WaitForTextAsync(string id, string expected, TimeSpan timeout),Task SelectComboBoxItemAsync(string id, string item),Task<Bitmap> CaptureScreenshotAsync()FlaUiDriverimplementation backed byFlaUI.UIA3.UIA3AutomationFakeUiDriverfor in-test verification of scenario logic without a window
AutomationProperties.AutomationIdattributes on the operator-touched controls inMainWindow.xaml:ConnectButton,DisconnectButton,RefreshCatalogButton,LoadRecipeButton,HomeButton,StartRunButton,StopButton,ApplySimulatorProfileButtonRecipeSelectorComboBox,SimulatorProfileSelectorComboBoxWorkflowStateText,ConnectionStateText,DefectCountText— the readouts scenarios use as wait-conditions- any other button or text element a scenario needs to see — the spec does not enumerate every binding, but every element accessed by FlaUI must have a stable id
- two scenarios as plain
[Fact]xUnit tests in the new project:DemoBaselineFlaUi— mirrors the §4.1 step list withawait driver.ClickByAutomationIdAsync("ConnectButton")andawait driver.WaitForTextAsync("ConnectionStateText", "Connected", TimeSpan.FromSeconds(10))etc.MultiTagSoakFlaUi— mirrors the §4.2 step list with the same pattern, profile selection first- both take a duration parameter via
[InlineData]or environment variable so they can be invoked at scenario length (not unit-test length); a separate[Trait("Category", "Capture")]annotation isolates them from the regular test run
- a thin orchestrator
tools/Capture-Measurements.ps1(re-introduced, but now a small wrapper, not the old behemoth):- parameters:
-Scenario,-DurationSeconds,-OutputCsv,-CommitHash,-Profile - sequence: build Release → launch
InspectionPrototype.App.exe(visible window) → wait for it to render → spawndotnet-counters collectagainst the PID → spawndotnet testin the AcceptanceTests project filtering to the named scenario → wait for the test to complete → stopdotnet-counters→ close the app via FlaUI's window-close (or fall back toStop-Processif the close path hangs) → run the existingConvertTo-MeasurementRowfromMeasurementExtraction.psm1
- parameters:
- a runbook §3a refresh:
- the current §3a (sleep-disable powercfg recipe + retired-rig pointer) is updated to point at the new rig
- an additional subsection on FlaUI prerequisites (foreground window required, screen lock will fail captures, Windows display scaling != 100% may shift element coords)
- the deferred
slice-1-2-real-frame-payloadsrow gets captured under the new rig and appended tophase-1-measurements.md(closing TASK-1.2 Pass 3)
Out of Scope
- CI integration. FlaUI needs a foreground window which doesn't exist in headless CI runners without RDP / VM display configuration. Future SLICE if and when CI captures become a goal.
- re-capturing rows 0, 0a, 0b, or
slice-1-1-multi-tag-telemetry. They were captured under the retired rig but their numbers are still valid measurements; deltas across rows that mix capture rigs are noted in the row block notes, not erased. - a
FlaUI-based replacement for the existingdotnet testxUnit suite. The 313 existing unit tests stay xUnit-pure; only the new acceptance-test project depends on FlaUI. - driving the simulator profile selector via XAML automation if doing so is intrinsically flaky (some
ComboBoxpatterns require multiple clicks orExpandCollapsePatterninvocation order). If the obvious FlaUI path doesn't work cleanly, the spec allows a configuration-via-CLI-arg fallback (InspectionPrototype.App.exe --start-with-profile MultiTag) added under SLICE-1.6 itself. - accessibility audit, screenshot-diff regression testing, or any FlaUI usage beyond what the two scenarios need.
- migrating the
Get-GcPauseP95/Get-LohAllocRateAvgextraction helpers — they're already correct from SLICE-1.2 Pass 2 and reused unchanged.
Runtime Behavior
Driver and scenario flow
FlaUiDrivertakes aProcess(the launched app) at construction. It opens UIA3 and usesApplication.Attach(process)to bind. The driver does not start or stop the app — that's the orchestrator's job.ClickByAutomationIdAsync(id, timeout)polls the AutomationElement tree at 100 ms intervals until the element is found andIsEnabled, then invokes itsInvokePattern.Invoke(). Times out with a clear message naming the id and the element tree's last-known state.WaitForTextAsync(id, expected, timeout)polls the named element'sText(orName) at 100 ms intervals until equal or timeout. The 100 ms cadence is deliberately conservative — UIA3 calls have non-trivial overhead and tighter polling can starve the dispatcher in long captures.CaptureScreenshotAsync()is wired but optional; only used when a scenario's wait fails, to attach the visual state to the failure message.
Scenarios
DemoBaselineFlaUi:driver.Click("ConnectButton") driver.WaitForText("ConnectionStateText", "Connected", 10s) driver.Click("RefreshCatalogButton") driver.WaitForRecipeCatalogPopulated(10s) driver.SelectComboBoxItem("RecipeSelectorComboBox", "Standard 5-Point Wafer Scan") driver.Click("LoadRecipeButton") driver.WaitForText("WorkflowStateText", "Idle", 10s) driver.Click("HomeButton") driver.WaitForText("WorkflowStateText", "Ready", 30s) while (!ct.IsCancellationRequested) { driver.Click("StartRunButton") driver.WaitForText("WorkflowStateText", "Running", 10s) driver.WaitForRunCompletion(60s) // text becomes "Completed" or terminal driver.Click("HomeButton") driver.WaitForText("WorkflowStateText", "Ready", 30s) } driver.Click("StopButton") if running driver.Click("DisconnectButton")MultiTagSoakFlaUiis identical except for an initial profile-selection step:driver.SelectComboBoxItem("SimulatorProfileSelectorComboBox", "MultiTag") driver.Click("ApplySimulatorProfileButton") driver.WaitForText("ActiveSimulatorProfileText", "MultiTag", 5s) ... rest identical to DemoBaselineFlaUi- The inter-run Home is in the loop body — same as the corrected manual procedure (TASK-1.5.1 Pass 1's runbook fix).
Orchestrator
tools/Capture-Measurements.ps1(new, smaller — ~80 lines vs the retired 200+):- asserts working tree clean (
git diff-index --quiet HEAD --) unless-AllowDirty - builds Release
- launches the app visibly (
Start-Processwith default window style; do not pass-WindowStyle Hidden) - polls
dotnet-counters psfor the PID, then startsdotnet-counters collect - invokes
dotnet test --filter "FullyQualifiedName=InspectionPrototype.AcceptanceTests.<Scenario>"with environment variables set forDURATION_SECONDSandAPP_PROCESS_ID - the test reads the env vars, attaches FlaUI to the named PID, runs the scenario for the given duration, exits
- orchestrator stops
dotnet-counters, closes the app via FlaUI's window-close (orStop-Processfallback after a 5 s grace period), runsConvertTo-MeasurementRow, prints the row block (or appends with-AppendToTable)
- asserts working tree clean (
XAML automation IDs
- All operator-touched controls in
MainWindow.xamlgetAutomationProperties.AutomationId="<StableName>". - Convention: PascalCase, suffix matches control type (
Button,ComboBox,Text). - This is a one-time audit; future XAML changes must preserve the ids that scenarios depend on. A small build-time check (a Roslyn analyzer or an xUnit reflection test against
MainWindow.xamlparsed as XML) verifies the canonical id set is present — mechanism choice deferred to TASK-1.6.
Acceptance Criteria
This slice is satisfied only if all of the following are true:
- A
tests/InspectionPrototype.AcceptanceTestsproject exists withFlaUI.CoreandFlaUI.UIA3package references (centralized inDirectory.Packages.props),<TargetFramework>net10.0-windows</TargetFramework>, and one xUnit class per scenario (DemoBaselineFlaUi,MultiTagSoakFlaUi). IUiDriver,FlaUiDriver, andFakeUiDriverexist;FakeUiDriveris reachable from xUnit tests for unit-testing scenario logic without a window.MainWindow.xamlhas stableAutomationProperties.AutomationIdattributes on every control the two scenarios touch (the ten enumerated in §"In Scope" plus any added during implementation). A failingdotnet testrun when an id is removed or renamed is the regression signal.tools/Capture-Measurements.ps1exists in its new (smaller) form, refuses to run with a dirty working tree unless-AllowDirty, and produces the same 18-metric markdown row block format as the retired rig (the format that closed SLICE-1.2 Pass 2's extraction work).- Running
tools/Capture-Measurements.ps1 -Scenario DemoBaseline -DurationSeconds 600 -OutputCsv ...end-to-end produces a valid CSV and an 18-metric row block; the app's WorkflowState transitions match what a manual operator would produce; noruns.faultedfor UI-driven faults. - The deferred
slice-1-2-real-frame-payloadsrow is captured (10-min HighFrameRate scenario via the new rig) and appended todocs/reviews/phase-1-measurements.md. Passes acceptance criterion 6 of SLICE-1.2 (frames.dropped = 0, frames.ingested ≥ 17 500). - Runbook §3a is updated with FlaUI prerequisites (foreground window, no screen lock, 100% display scaling recommended) and the new orchestrator's invocation. The retirement pointer for SLICE-1.5 / SLICE-1.5.1 is preserved.
- The full existing test suite (the 313 xUnit tests in
InspectionPrototype.Tests) still passes, plus a smaller new test inInspectionPrototype.AcceptanceTestsexercisingFakeUiDriverto verify scenario flow without launching the app. - The two scenario
[Fact]s carry[Trait("Category", "Capture")]so they're filterable; they do NOT run as part ofdotnet testwithout an explicit filter (otherwise every CI run tries to launch a window). - SLICE-1.5 and SLICE-1.5.1's "Status: Superseded" banners are updated to reference SLICE-1.6 as the actual replacement (no longer "planned"). CLAUDE.md and roadmap-progress.md are updated.
Verification Notes
The implementation task for this spec must include verification for:
- the
AutomationIdaudit catches every element the scenarios touch — verified by deliberately removing one id and confirming the corresponding scenario[Fact]fails with an actionable message naming the missing id - the FlaUI element-find timeout default (the 100 ms polling × N seconds) doesn't accidentally race against the WPF dispatcher's render queue at high frame rates (HighFrameRate profile = 30 fps frames + 50 tags). A smoke run in HighFrameRate that completes one full cycle proves this
- the orchestrator's window-close path closes the app cleanly without the post-scenario host shutdown delay we hit on the retired rig (which turned out to be system sleep, not a code bug — but the new rig should behave the same way: window close → dispatcher exits → host disposes within a few seconds)
FakeUiDriver's scenario tests don't require any FlaUI dependencies at runtime — i.e., scenarios written againstIUiDriverare testable inInspectionPrototype.Tests(plain xUnit) without pulling FlaUI into that project. If this requires movingIUiDriverinto a shared library, the right home is a newInspectionPrototype.UiDriverproject; do that if needed- the deferred
slice-1-2-real-frame-payloadsrow's gc-pause-p95 and LOH-alloc-rate-avg numbers are non-zero (criterion 6 of SLICE-1.2 specifies allocation pressure should be visible; if the new rig somehow masks it, that's worth flagging in the row notes) - if FlaUI proves intrinsically flaky on the simulator-profile-selector ComboBox (some
ComboBoxpatterns requireExpandCollapsePatterninvocation order), the fallback--start-with-profile <name>CLI flag added in this slice is the resolution path; the slice does NOT punt that to a follow-up