Skip to content

SLICE-1.6: FlaUI-Driven Measurement Capture

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 the CanExecuteChanged plumbing — 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 IScenario class, an IOperatorCommands mapping, 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

In Scope

  • a new test project tests/InspectionPrototype.AcceptanceTests (.NET 10, Windows-only via <TargetFramework>net10.0-windows</TargetFramework>) referencing:
    • FlaUI.Core and FlaUI.UIA3 from NuGet (centralized in Directory.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:
    • IUiDriver with 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()
    • FlaUiDriver implementation backed by FlaUI.UIA3.UIA3Automation
    • FakeUiDriver for in-test verification of scenario logic without a window
  • AutomationProperties.AutomationId attributes on the operator-touched controls in MainWindow.xaml:
    • ConnectButton, DisconnectButton, RefreshCatalogButton, LoadRecipeButton, HomeButton, StartRunButton, StopButton, ApplySimulatorProfileButton
    • RecipeSelectorComboBox, SimulatorProfileSelectorComboBox
    • WorkflowStateText, 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 with await driver.ClickByAutomationIdAsync("ConnectButton") and await 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 → spawn dotnet-counters collect against the PID → spawn dotnet test in the AcceptanceTests project filtering to the named scenario → wait for the test to complete → stop dotnet-counters → close the app via FlaUI's window-close (or fall back to Stop-Process if the close path hangs) → run the existing ConvertTo-MeasurementRow from MeasurementExtraction.psm1
  • 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-payloads row gets captured under the new rig and appended to phase-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 existing dotnet test xUnit 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 ComboBox patterns require multiple clicks or ExpandCollapsePattern invocation 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-LohAllocRateAvg extraction helpers — they're already correct from SLICE-1.2 Pass 2 and reused unchanged.

Runtime Behavior

Driver and scenario flow

  • FlaUiDriver takes a Process (the launched app) at construction. It opens UIA3 and uses Application.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 and IsEnabled, then invokes its InvokePattern.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's Text (or Name) 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")
  • MultiTagSoakFlaUi is 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-Process with default window style; do not pass -WindowStyle Hidden)
    • polls dotnet-counters ps for the PID, then starts dotnet-counters collect
    • invokes dotnet test --filter "FullyQualifiedName=InspectionPrototype.AcceptanceTests.<Scenario>" with environment variables set for DURATION_SECONDS and APP_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 (or Stop-Process fallback after a 5 s grace period), runs ConvertTo-MeasurementRow, prints the row block (or appends with -AppendToTable)

XAML automation IDs

  • All operator-touched controls in MainWindow.xaml get AutomationProperties.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.xaml parsed 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:

  1. A tests/InspectionPrototype.AcceptanceTests project exists with FlaUI.Core and FlaUI.UIA3 package references (centralized in Directory.Packages.props), <TargetFramework>net10.0-windows</TargetFramework>, and one xUnit class per scenario (DemoBaselineFlaUi, MultiTagSoakFlaUi).
  2. IUiDriver, FlaUiDriver, and FakeUiDriver exist; FakeUiDriver is reachable from xUnit tests for unit-testing scenario logic without a window.
  3. MainWindow.xaml has stable AutomationProperties.AutomationId attributes on every control the two scenarios touch (the ten enumerated in §"In Scope" plus any added during implementation). A failing dotnet test run when an id is removed or renamed is the regression signal.
  4. tools/Capture-Measurements.ps1 exists 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).
  5. 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; no runs.faulted for UI-driven faults.
  6. The deferred slice-1-2-real-frame-payloads row is captured (10-min HighFrameRate scenario via the new rig) and appended to docs/reviews/phase-1-measurements.md. Passes acceptance criterion 6 of SLICE-1.2 (frames.dropped = 0, frames.ingested ≥ 17 500).
  7. 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.
  8. The full existing test suite (the 313 xUnit tests in InspectionPrototype.Tests) still passes, plus a smaller new test in InspectionPrototype.AcceptanceTests exercising FakeUiDriver to verify scenario flow without launching the app.
  9. The two scenario [Fact]s carry [Trait("Category", "Capture")] so they're filterable; they do NOT run as part of dotnet test without an explicit filter (otherwise every CI run tries to launch a window).
  10. 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 AutomationId audit 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 against IUiDriver are testable in InspectionPrototype.Tests (plain xUnit) without pulling FlaUI into that project. If this requires moving IUiDriver into a shared library, the right home is a new InspectionPrototype.UiDriver project; do that if needed
  • the deferred slice-1-2-real-frame-payloads row'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 ComboBox patterns require ExpandCollapsePattern invocation 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

Docs-first project memory for AI-assisted implementation.