diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml
index d794d617e..877f30f49 100644
--- a/.github/workflows/build-package.yml
+++ b/.github/workflows/build-package.yml
@@ -164,6 +164,19 @@ jobs:
$platform = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" }
.\scripts\test-e2e-winui-ui.ps1 -WinAppPath "artifacts/cli/win-$platform/winapp.exe"
+ # Gated cooperative desktop-turn coverage (issue #764). Runs here, on the interactive lane, because
+ # it drives real winapp.exe processes against a real foreground window.
+ - name: Run UI coordination multiprocess and real-app tests
+ run: .\scripts\test-ui-coordination.ps1
+
+ - name: Upload UI coordination test results
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: ui-coordination-test-results
+ path: artifacts/TestResults/ui-coordination/
+ if-no-files-found: warn
+
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
diff --git a/docs/cli-schema.json b/docs/cli-schema.json
index 257aaf596..3fe51c74c 100644
--- a/docs/cli-schema.json
+++ b/docs/cli-schema.json
@@ -4927,6 +4927,57 @@
"recursive": false
}
}
+ },
+ "yield": {
+ "description": "Release the current workflow's idle UI turn early. A workflow with WINAPP_UI_WORKFLOW_ID keeps the desktop for a few seconds after each command so a burst of commands reads as one workflow; run this after the final command of a workflow to hand the desktop to waiting workflows straight away. Requires WINAPP_UI_WORKFLOW_ID; targets no app and takes no selector.",
+ "hidden": false,
+ "options": {
+ "--json": {
+ "description": "Format output as JSON",
+ "hidden": false,
+ "valueType": "System.Boolean",
+ "hasDefaultValue": true,
+ "defaultValue": false,
+ "arity": {
+ "minimum": 0,
+ "maximum": 1
+ },
+ "required": false,
+ "recursive": false
+ },
+ "--quiet": {
+ "description": "Suppress progress messages",
+ "hidden": false,
+ "aliases": [
+ "-q"
+ ],
+ "valueType": "System.Boolean",
+ "hasDefaultValue": true,
+ "defaultValue": false,
+ "arity": {
+ "minimum": 0,
+ "maximum": 1
+ },
+ "required": false,
+ "recursive": false
+ },
+ "--verbose": {
+ "description": "Enable verbose output",
+ "hidden": false,
+ "aliases": [
+ "-v"
+ ],
+ "valueType": "System.Boolean",
+ "hasDefaultValue": true,
+ "defaultValue": false,
+ "arity": {
+ "minimum": 0,
+ "maximum": 1
+ },
+ "required": false,
+ "recursive": false
+ }
+ }
}
}
},
diff --git a/docs/npm-usage.md b/docs/npm-usage.md
index 606c88b48..d8f5c3689 100644
--- a/docs/npm-usage.md
+++ b/docs/npm-usage.md
@@ -44,6 +44,8 @@ Base options shared by most commands.
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `WinappResult`
@@ -57,7 +59,7 @@ Result returned by every command wrapper.
## CLI command wrappers
-These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).
+These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).
### `azSign()`
@@ -78,7 +80,7 @@ function azSign(options: AzSignOptions): Promise
| `resourceGroup` | `string \| undefined` | No | Resource group to narrow down signing accounts |
| `subscription` | `string \| undefined` | No | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -104,7 +106,7 @@ function certGenerate(options?: CertGenerateOptions): Promise
| `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for the generated certificate (e.g., CN=MyCompany or OU=Team, O=Corp, C=US). If not specified, will be inferred from manifest. Bare names are auto-wrapped as CN=. |
| `validDays` | `number \| undefined` | No | Number of days the certificate is valid |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -124,7 +126,7 @@ function certInfo(options: CertInfoOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `password` | `string \| undefined` | No | Password for the PFX file |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -144,7 +146,7 @@ function certInstall(options: CertInstallOptions): Promise
| `force` | `boolean \| undefined` | No | Force installation even if the certificate already exists |
| `password` | `string \| undefined` | No | Password for the PFX file |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -165,7 +167,7 @@ function createDebugIdentity(options?: CreateDebugIdentityOptions): Promise
| `target` | `string` | Yes | Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist). |
| `manifest` | `string \| undefined` | No | Path to the sparse appxmanifest.xml to read identity from. When omitted, searched in a 'sparse/' folder (where 'winapp init --exe --sparse' writes it by default) beside the target first, then in the current directory, then beside the target and in the current directory. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -231,7 +233,7 @@ function findUi(options?: FindUiOptions): Promise
| `refresh` | `boolean \| undefined` | No | Bypass the local cache and re-fetch the WinUI corpus from GitHub. |
| `source` | `string \| undefined` | No | Restrict results to a single source: gallery (WinUI 3 Gallery), toolkit (Windows Community Toolkit), reactor (microsoft-ui-reactor, C#-only declarative WinUI), or core (curated patterns). Reactor is opt-in — it is excluded from a normal search, so pass --source reactor to search it (only do this for a Reactor/MVU project; its C#-only samples don't paste into a standard XAML app). |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -249,7 +251,7 @@ function getWinappPath(options?: GetWinappPathOptions): Promise
|----------|------|----------|-------------|
| `global` | `boolean \| undefined` | No | Get the global .winapp directory instead of local |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -279,7 +281,7 @@ function init(options?: InitOptions): Promise
| `sparse` | `boolean \| undefined` | No | Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation. |
| `useDefaults` | `boolean \| undefined` | No | Skip interactive prompts and use default answers. Normal init targets the positional project directory if given, otherwise the current directory (e.g., winapp init . --use-defaults). Sparse init (--exe --sparse) ignores the positional directory and writes to --output-dir instead. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -299,7 +301,7 @@ function manifestAddAlias(options?: ManifestAddAliasOptions): Promise
| `templateVersion` | `string \| undefined` | No | WinUI template pack version: 'latest' (install newest), 'installed' (keep what's installed), or an explicit version. Default: install latest if none, else prompt to update a stale pack. |
| `useDefaults` | `boolean \| undefined` | No | Do not prompt; use defaults (blank template, name from --output/--name, keep installed templates). |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -399,7 +401,7 @@ function packageApp(options: PackageOptions): Promise
| `selfContained` | `boolean \| undefined` | No | Bundle Windows App SDK runtime for self-contained deployment |
| `skipPri` | `boolean \| undefined` | No | Skip PRI file generation |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -418,7 +420,7 @@ function restore(options?: RestoreOptions): Promise
| `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace |
| `configDir` | `string \| undefined` | No | Directory to read configuration from (default: base-directory) |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -458,7 +460,7 @@ function run(options?: RunOptions): Promise
| `withAlias` | `boolean \| undefined` | No | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. |
| `appArgs` | `string \| string[] \| undefined` | No | Arguments to pass to the launched application (forwarded after --). |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -479,7 +481,7 @@ function sign(options: SignOptions): Promise
| `password` | `string \| undefined` | No | Certificate password |
| `timestamp` | `string \| undefined` | No | Timestamp server URL |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -497,7 +499,7 @@ function store(options?: StoreOptions): Promise
|----------|------|----------|-------------|
| `storeArgs` | `string \| string[] \| undefined` | No | Arguments to pass through to the Microsoft Store Developer CLI. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -515,7 +517,7 @@ function tool(options?: ToolOptions): Promise
|----------|------|----------|-------------|
| `toolArgs` | `string \| string[] \| undefined` | No | Arguments to pass to the SDK tool, e.g. ['makeappx', 'pack', '/d', './folder', '/p', './out.msix']. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -538,7 +540,7 @@ function uiClick(options?: UiClickOptions): Promise
| `right` | `boolean \| undefined` | No | Perform a right-click instead of a left click |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -563,7 +565,7 @@ function uiDrag(options?: UiDragOptions): Promise
| `right` | `boolean \| undefined` | No | Drag with the right mouse button instead of the left button |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -584,7 +586,7 @@ function uiFocus(options?: UiFocusOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -604,7 +606,7 @@ function uiGetFocused(options?: UiGetFocusedOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -626,7 +628,7 @@ function uiGetProperty(options?: UiGetPropertyOptions): Promise
| `property` | `string \| undefined` | No | Property name to read or filter on |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -647,7 +649,7 @@ function uiGetValue(options?: UiGetValueOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -669,7 +671,7 @@ function uiHover(options?: UiHoverOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -695,7 +697,7 @@ function uiInspect(options?: UiInspectOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -716,7 +718,7 @@ function uiInvoke(options?: UiInvokeOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -736,7 +738,7 @@ function uiListWindows(options?: UiListWindowsOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `showHidden` | `boolean \| undefined` | No | Include untitled zero-size windows that are hidden by default |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -764,7 +766,7 @@ function uiPen(options?: UiPenOptions): Promise
| `tiltY` | `number \| undefined` | No | Pen tilt along the y-axis in degrees (-90 to 90, default: 0). |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -788,7 +790,7 @@ function uiScreenshot(options?: UiScreenshotOptions): Promise
| `output` | `string \| undefined` | No | Save output to this file path. |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -812,7 +814,7 @@ function uiScroll(options?: UiScrollOptions): Promise
| `wheel` | `number \| undefined` | No | Rotate the mouse wheel over the element by this many notches (1 = one notch up, -1 = one notch down). Synthesizes real wheel input instead of using ScrollPattern. |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -833,7 +835,7 @@ function uiScrollIntoView(options?: UiScrollIntoViewOptions): Promise
| `max` | `number \| undefined` | No | Maximum search results |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -871,7 +873,7 @@ function uiSendKeys(options?: UiSendKeysOptions): Promise
| Property | Type | Required | Description |
|----------|------|----------|-------------|
-| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \s \t \n \r \\ are supported (text=a\s\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". |
+| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \\s \\t \\n \\r \\\\ are supported (text=a\\s\\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". |
| `allowSystemKeys` | `boolean \| undefined` | No | Allow synthesizing system-/shell-reserved combos (win+, alt+f4, alt+tab, ctrl+esc, …) via --via send-input, which are refused by default because they act on the OS/shell beyond the target app. Opt in to drive global hotkeys (e.g. PowerToys' win+shift+v, win+r). No effect on --via post-message (already window-scoped; a warning is emitted if set without send-input). Note: win+l and ctrl+alt+del stay blocked even with this flag — win+l locks the workstation (LockWorkStation() via the shell hook), which is unrecoverable from automation, and ctrl+alt+del is a Secure Attention Sequence (SAS) that Windows drops from injected input regardless of this flag, so it can never take effect. |
| `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. |
| `json` | `boolean \| undefined` | No | Format output as JSON |
@@ -880,7 +882,7 @@ function uiSendKeys(options?: UiSendKeysOptions): Promise
| `via` | `string \| undefined` | No | Transport: post-message (default, HWND-targeted, bypasses UIPI; typed text raises TextChanged but not a per-character KeyDown) or send-input (OS-wide; typed text raises a real per-character KeyDown + TextChanged). Named keys and combos raise KeyDown on both, but keyboard accelerators/shortcuts (KeyboardAccelerator, e.g. ctrl+t) only fire via send-input. post-message targets the focused child control and works for classic Win32/WinForms controls, but WinUI 3 / UWP / XAML controls are windowless and ignore posted messages — use send-input for those (a warning is emitted when the target looks like a XAML app). |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -902,7 +904,7 @@ function uiSetValue(options?: UiSetValueOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -922,7 +924,7 @@ function uiStatus(options?: UiStatusOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -951,7 +953,7 @@ function uiTouch(options?: UiTouchOptions): Promise
| `toPoint` | `string \| undefined` | No | End point x,y for a swipe (screen coordinates). Takes precedence over --direction. |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -977,7 +979,25 @@ function uiWaitFor(options?: UiWaitForOptions): Promise
| `value` | `string \| undefined` | No | Wait for element value to equal this string. Uses smart fallback (TextPattern -> ValuePattern -> Name). Combine with --property to check a specific property instead. |
| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
+
+---
+
+### `uiYield()`
+
+Release the current workflow's idle UI turn early. A workflow with WINAPP_UI_WORKFLOW_ID keeps the desktop for a few seconds after each command so a burst of commands reads as one workflow; run this after the final command of a workflow to hand the desktop to waiting workflows straight away. Requires WINAPP_UI_WORKFLOW_ID; targets no app and takes no selector.
+
+```typescript
+function uiYield(options?: UiYieldOptions): Promise
+```
+
+**Options:**
+
+| Property | Type | Required | Description |
+|----------|------|----------|-------------|
+| `json` | `boolean \| undefined` | No | Format output as JSON |
+
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -997,7 +1017,7 @@ function unregister(options?: UnregisterOptions): Promise
| `json` | `boolean \| undefined` | No | Format output as JSON |
| `manifest` | `string \| undefined` | No | Path to the Package.appxmanifest (default: auto-detect from current directory) |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -1015,7 +1035,7 @@ function update(options?: UpdateOptions): Promise
|----------|------|----------|-------------|
| `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) |
-*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*
+*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).*
---
@@ -1263,6 +1283,8 @@ Re-exported from Node.js for convenience. See [Node.js docs](https://nodejs.org/
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `exitOnError` | `boolean \| undefined` | No | |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
On Windows, Node force-terminates the child, so the CLI's own cleanup may not run. That is safe: Windows closes the process's coordination file handles and deletes its `DeleteOnClose` participant lease, and other `winapp ui` processes prune the entry through lease and PID/start validation. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial or invalid output — this wrapper does not promise graceful MP4 finalization.
Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls that pass the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not you set this. What a workflow id adds is *continuity*: commands sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input.
Without it each call is a self-contained one-shot that releases the desktop the moment it finishes.
Applied to the spawned child only; `process.env` is never modified. |
### `CallWinappCliResult`
@@ -1275,6 +1297,8 @@ Re-exported from Node.js for convenience. See [Node.js docs](https://nodejs.org/
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()) |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation. See {@link CallWinappCliOptions.signal} for the exact contract, including what is and is not guaranteed after an abort. |
+| `workflowId` | `string \| undefined` | No | Groups this call into one logical UI workflow. See {@link CallWinappCliOptions.workflowId} for what continuity buys and why arbitration does not depend on it. |
### `CallWinappCliCaptureResult`
@@ -1367,6 +1391,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `CertGenerateOptions`
@@ -1384,6 +1410,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `CertInfoOptions`
@@ -1395,6 +1423,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `CertInstallOptions`
@@ -1406,6 +1436,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `CreateDebugIdentityOptions`
@@ -1418,6 +1450,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `CreateExternalCatalogOptions`
@@ -1432,6 +1466,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `EmbedIdentityOptions`
@@ -1442,6 +1478,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `FindUiOptions`
@@ -1457,6 +1495,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `GetWinappPathOptions`
@@ -1466,6 +1506,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `InitOptions`
@@ -1487,6 +1529,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `ManifestAddAliasOptions`
@@ -1498,6 +1542,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `ManifestGenerateOptions`
@@ -1515,6 +1561,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `ManifestUpdateAssetsOptions`
@@ -1526,6 +1574,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `NewOptions`
@@ -1542,6 +1592,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `PackageOptions`
@@ -1562,6 +1614,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `RestoreOptions`
@@ -1572,6 +1626,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `RunOptions`
@@ -1603,6 +1659,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `SignOptions`
@@ -1615,6 +1673,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `StoreOptions`
@@ -1624,6 +1684,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `ToolOptions`
@@ -1633,6 +1695,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiClickOptions`
@@ -1647,6 +1711,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiDragOptions`
@@ -1663,6 +1729,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiFocusOptions`
@@ -1675,6 +1743,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiGetFocusedOptions`
@@ -1686,6 +1756,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiGetPropertyOptions`
@@ -1699,6 +1771,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiGetValueOptions`
@@ -1711,6 +1785,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiHoverOptions`
@@ -1724,6 +1800,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiInspectOptions`
@@ -1741,6 +1819,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiInvokeOptions`
@@ -1753,6 +1833,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiListWindowsOptions`
@@ -1764,6 +1846,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiPenOptions`
@@ -1783,6 +1867,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiScreenshotOptions`
@@ -1798,6 +1884,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiScrollOptions`
@@ -1813,6 +1901,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiScrollIntoViewOptions`
@@ -1825,6 +1915,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiSearchOptions`
@@ -1838,12 +1930,14 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiSendKeysOptions`
| Property | Type | Required | Description |
|----------|------|----------|-------------|
-| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \s \t \n \r \\ are supported (text=a\s\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". |
+| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \\s \\t \\n \\r \\\\ are supported (text=a\\s\\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". |
| `allowSystemKeys` | `boolean \| undefined` | No | Allow synthesizing system-/shell-reserved combos (win+, alt+f4, alt+tab, ctrl+esc, …) via --via send-input, which are refused by default because they act on the OS/shell beyond the target app. Opt in to drive global hotkeys (e.g. PowerToys' win+shift+v, win+r). No effect on --via post-message (already window-scoped; a warning is emitted if set without send-input). Note: win+l and ctrl+alt+del stay blocked even with this flag — win+l locks the workstation (LockWorkStation() via the shell hook), which is unrecoverable from automation, and ctrl+alt+del is a Secure Attention Sequence (SAS) that Windows drops from injected input regardless of this flag, so it can never take effect. |
| `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. |
| `json` | `boolean \| undefined` | No | Format output as JSON |
@@ -1854,6 +1948,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiSetValueOptions`
@@ -1867,6 +1963,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiStatusOptions`
@@ -1878,6 +1976,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiTouchOptions`
@@ -1898,6 +1998,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UiWaitForOptions`
@@ -1915,6 +2017,19 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
+
+### `UiYieldOptions`
+
+| Property | Type | Required | Description |
+|----------|------|----------|-------------|
+| `json` | `boolean \| undefined` | No | Format output as JSON |
+| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
+| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
+| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UnregisterOptions`
@@ -1926,6 +2041,8 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
### `UpdateOptions`
@@ -1935,4 +2052,6 @@ type ManifestTemplates = "packaged" | "sparse"
| `quiet` | `boolean \| undefined` | No | Suppress progress messages. |
| `verbose` | `boolean \| undefined` | No | Enable verbose output. |
| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). |
+| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.
`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. |
+| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.
Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.
Applied to the spawned child process only; `process.env` is never modified. |
diff --git a/docs/telemetry.md b/docs/telemetry.md
index 40049dea8..6e812d1d0 100644
--- a/docs/telemetry.md
+++ b/docs/telemetry.md
@@ -88,6 +88,7 @@ The telemetry feature collects the following data:
| Caller | The value of the `WINAPP_CLI_CALLER` environment variable, if set. This allows wrapper tools (like the npm package) to identify themselves. |
| Project context | For `init`, `run`, `restore`, `update`, and `package`, a separate correlated event records bounded categories describing the project family (`dotnet`, `node`, `cpp`, `rust`, `dart`, `hybrid`, `mixed`, or `unknown`), recognized app framework (such as `winui`, `wpf`, `winforms`, `maui`, `electron`, `tauri`, `flutter`, `react-native-windows`, `avalonia`, `uwp`, `windows-app-sdk`, or `other-dotnet`), target kind, detection source, confidence, packaging model, and whether `run` used project or folder execution. It doesn't include project names, paths, repositories, versions, dependency lists, or source. |
| `find-ui` usage | For the `winapp find-ui` command only, an additional usage event with non-personal, bounded values: the mode (`search`, `fetch`, or `list`); the selected `--source` (a fixed value — `gallery`, `toolkit`, `reactor`, or `core`); the catalog scenario IDs fetched (e.g. `gallery-tabview-1`), which identify built-in WinUI sample controls, never your code; whether `--json` was used; and result/ID counts. The free-form search query is **never** collected, and any requested IDs that don't match a real catalog entry are counted but **not** collected as text. |
+| `winapp ui` desktop coordination | For `winapp ui` commands only, a privacy-minimized summary of how the command shared the desktop with other UI workflows: how the workflow identity was resolved (`Workflow` or `Anonymous` — never the identity itself), the coordination mode (`Observe`, `TurnShared`, or `DesktopExclusive`), how the turn was obtained (new, continuation, queued, handoff-after-idle, or detached), the outcome (completed, cancelled, coordination failure, or corruption recovery), and **coarse buckets** for how long this command waited for the desktop, how many commands were queued, and how long the owning workflow had held its turn. |
### Sanitization of sensitive data
@@ -98,6 +99,7 @@ The winapp CLI takes several measures to protect your privacy:
- **Parsing errors** are logged as `[error]` without including the actual erroneous input.
- **Project classification** emits only fixed, allow-listed category values. Unknown or unrecognized metadata is reported as `unknown` or a generic family category rather than transmitted as text.
- All string values in telemetry events undergo **sensitive string replacement** before transmission, which replaces any registered sensitive strings with anonymized tokens.
+- **Desktop coordination** never collects the `WINAPP_UI_WORKFLOW_ID` value or its hash, process IDs, process or application names, window titles, selectors, element text, queue contents, or any part of the coordination state files. Durations and counts are reported only as fixed buckets (for example `1000-4999`), never as exact values.
## Crash exception telemetry
diff --git a/docs/ui-automation.md b/docs/ui-automation.md
index a6134eb14..a78266cd7 100644
--- a/docs/ui-automation.md
+++ b/docs/ui-automation.md
@@ -13,7 +13,7 @@ Uses Windows UI Automation (UIA). Works with any Windows app — WPF, WinForms,
Most commands drive the app through UIA patterns (no input injection). The exceptions inject real input: `ui click`/`ui hover`/`ui drag` use mouse simulation, `ui touch`/`ui pen` synthesize touch and pen/stylus input, and `ui send-keys` synthesizes keyboard input — for controls and scenarios that UIA patterns can't drive.
> [!IMPORTANT]
-> **Interactive-desktop requirement (input-injecting verbs).** `click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, and `send-keys --via send-input` synthesize OS-level input, so they need an **unlocked, interactive desktop** with the target window in the foreground. On a **locked workstation or secure desktop** (LogonUI/UAC) they can't inject and fail fast with **`no_interactive_desktop`** (distinct from the elevation/`foreground_not_target` cases). `touch`/`pen` additionally refuse when no window resolves (**`no_target`**); a coordinate outside the target window is a **non-fatal warning** (a `warnings[]` entry under `--json`, or a warning line in text mode) and injection still proceeds — consistent with the mouse verbs. Everything else — `inspect`, `search`, `get-property`, `get-value`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to`, `screenshot` — drives the app through UIA patterns and is **headless/locked-session friendly**. Prefer the UIA-pattern verbs in CI; reserve the injection verbs for scenarios that genuinely need real input. Before injecting, the gesture verbs also **re-resolve the target element** and refuse with **`target_moved`** if it's still animating/relocating, rather than landing input on empty space.
+> **Interactive-desktop requirement (input-injecting verbs).** `click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, and `send-keys --via send-input` synthesize OS-level input, so they need an **unlocked, interactive desktop** with the target window in the foreground. On a **locked workstation or secure desktop** (LogonUI/UAC) they can't inject and fail fast with **`no_interactive_desktop`** (distinct from the elevation/`foreground_not_target` cases). `touch`/`pen` additionally refuse when no window resolves (**`no_target`**); a coordinate outside the target window is a **non-fatal warning** (a `warnings[]` entry under `--json`, or a warning line in text mode) and injection still proceeds — consistent with the mouse verbs. Everything else — `inspect`, `search`, `get-property`, `get-value`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to` — drives the app through UIA patterns and is **headless/locked-session friendly**. `screenshot` is the exception among the non-injecting verbs: it takes an exclusive turn and its capture can need a usable interactive desktop, because the engine restores a minimized target and falls back to foregrounding it when frame capture is unavailable or `--capture-screen` is used. Prefer the UIA-pattern verbs in CI; reserve the injection verbs for scenarios that genuinely need real input. Before injecting, the gesture verbs also **re-resolve the target element** and refuse with **`target_moved`** if it's still animating/relocating, rather than landing input on empty space.
## Quick Start
@@ -31,6 +31,146 @@ winapp ui invoke Close -a notepad
winapp ui screenshot -a notepad
```
+## Coordinating concurrent UI workflows
+
+Windows has only one foreground window, one keyboard focus, one cursor, and one input stream. When
+two `winapp ui` workflows run on the same signed-in desktop at once, they can steal focus from each
+other, dismiss a menu the other just opened, or move a target out from under a pending click.
+
+**Arbitration is always on.** Every `winapp ui` command that touches the physical desktop takes a
+turn, with no setup and no way to switch it off, so two agents can never type into each other's
+windows. Read-only commands keep running concurrently.
+
+**Continuity between commands is opt-in.** By default each command is a self-contained one-shot: it
+waits its turn, does its work, and releases the desktop immediately. To keep the desktop across
+several commands, give them all the same workflow id:
+
+```powershell
+# Set once per logical UI workflow
+$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString()
+```
+
+What you need to know:
+
+- **A workflow id names one logical workflow** — not necessarily a whole agent, and not necessarily
+ one app. Use the *same* value for cooperating commands (a recording plus the clicks it should
+ capture); use *different* values for independent workflows, even when one agent launches both.
+- **With no id, every command is an independent one-shot.** It still arbitrates, but it banks no
+ grace and hands the desktop off the moment it finishes. Two no-id commands are separate workflows
+ even when launched from the same shell.
+- **Fresh-shell and adaptive hosts must inject the same value.** If each command runs in a new shell
+ — which is how most agent tool calls work — the only thing that can group them is an explicit
+ `WINAPP_UI_WORKFLOW_ID` passed into every cooperating call.
+- **The four-second grace protects tight bursts, not model reasoning.** A workflow with an id keeps
+ its turn as long as the next command starts within four seconds. That covers back-to-back commands
+ in one script; it intentionally expires while a model is thinking. It is a fallback for when you
+ cannot say you are finished — when you can, run `winapp ui yield` instead of waiting it out.
+- **Adaptive workflows must reacquire, revalidate, and replay.** After a reasoning gap another
+ workflow may have used the desktop, so reopen the menu, re-resolve the element, and then act.
+ Send known end-to-end sequences as one tight script rather than holding the desktop while you think.
+- **Ordering is owner affinity first, then FIFO among the others.** While a workflow is active or
+ inside its grace it may keep issuing commands, even if other workflows are already waiting. Once it
+ runs `winapp ui yield` or its grace expires, waiting workflows are served in strict arrival order.
+ Continuous activity by one workflow can therefore delay others indefinitely.
+- **There is no hard cap.** A long script, an unbounded recording, or a failure loop can block other
+ mutating workflows.
+- **Cancellation or process termination is the recovery** for a stuck live workflow. Waiting commands
+ print a status after one second and can be stopped with `Ctrl+C`, which exits `130`.
+- **Only compatible updated binaries cooperate.** Older `winapp` builds predate this feature and are
+ not coordinated. Code calling the UI Automation NuGet packages directly is outside this guarantee
+ entirely — coordination lives in the CLI, not in the packages.
+
+Which commands wait for a turn:
+
+| Behavior | Commands |
+|---|---|
+| Runs concurrently (never waits) | `status`, `list-windows`, `inspect`, `search`, `get-property`, `get-value`, `get-focused`, `wait-for` |
+| Waits for the turn but never takes the desktop | `set-value`, `scroll-into-view`, `scroll --direction`/`--to`, `record` |
+| Waits for the turn and takes the desktop exclusively | `invoke`, `click`, `drag`, `hover`, `scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot` |
+
+The middle row is the one worth understanding. `set-value`, `scroll-into-view` and
+`scroll --direction`/`--to` drive UIA patterns rather than the foreground, so they stay
+**headless/locked-session friendly** and never block anyone from using the desktop. But they *do*
+change what the app shows, so they wait behind another workflow's turn rather than editing a field or
+scrolling a list out from under somebody else's click.
+
+Within one workflow they overlap with other *shared* work — that is how a `record` captures the
+`set-value` calls it is recording. They do **not** ignore their own workflow's forward barrier: an
+earlier `DesktopExclusive` command of the same workflow (a `click`, a `screenshot`) still blocks
+them, exactly as it blocks every later command, so a click and the mutation that follows it stay in
+the order you wrote them.
+
+`screenshot` always queues for an exclusive turn. Not every capture disturbs the desktop — an
+ordinary visible window captured through Windows Graphics Capture does not — but the engine restores
+the target if it is minimized, and falls back to foregrounding it when frame capture is unavailable
+or `--capture-screen` reads the live screen. Those needs only surface once capture is under way, so
+the command takes the turn up front rather than guessing. When it composites several windows it
+captures them all under one exclusive turn, so the saved image is a single consistent moment rather
+than a mix of before and after. Encoding and writing the file happen after the desktop is released.
+
+> **`--capture-screen` needs exactly one window.** Live-screen capture records whatever is actually
+> in front, and only one window can be. Selecting a window explicitly with `-w ` gives it
+> exactly one region — the pixels inside that window's bounds, including any dialog or overlay
+> visibly on top of it, which is the reason to read the screen in the first place. When `-a` matches
+> several top-level or owned windows there is no such selection, so the command fails with
+> **`invalid_arguments`** before capturing anything rather than fighting the foreground. Run
+> `winapp ui list-windows -a ` and retry with `-w `, or drop `--capture-screen` to
+> composite every window from its own contents.
+
+`record` shares its turn, so same-workflow input can interleave with the capture — that is how you
+record a workflow driving an app. Two caveats:
+
+- A `record` with **no** workflow id is a one-shot owner, so it blocks every other workflow for its
+ whole duration. To record and click at the same time, give both commands the same
+ `WINAPP_UI_WORKFLOW_ID`.
+- On a host without frame-capture support, recording falls back to PrintWindow, whose blank-frame
+ recovery can foreground the window at any moment. There the desktop is held for the **entire**
+ recording and the command says so in its output; even same-workflow input will wait.
+
+Errors you may see: `invalid_ui_workflow_id` (the variable is set but empty or over 256 characters),
+`desktop_coordination_unavailable` (coordination state is unreadable and cannot be safely rebuilt, or
+was written by a newer `winapp`), `queue_capacity_exceeded` (64 commands from **other** workflows are
+already waiting — the limit counts live foreign waiters, not processes you have started, so entries
+belonging to commands that have exited or been killed do not occupy a slot, and your own workflow's
+commands queue behind each other rather than against this limit), `ui_turn_busy` (`yield` while your
+own workflow still has a command running), and `cancelled` (Ctrl+C while waiting, exit code `130`).
+
+### Releasing the turn early: `winapp ui yield`
+
+The four-second grace is a **fallback**: it keeps the desktop reserved when you cannot say for
+certain that you are finished. When you *can* say so, say so — `yield` hands the desktop over
+immediately instead of making everyone else wait out a grace nobody needs.
+
+```powershell
+$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString()
+
+winapp ui invoke File -a notepad
+winapp ui click "Save As..." -a notepad
+winapp ui set-value txt-filename-a1b2 "notes.txt" -a notepad
+winapp ui yield # done — a waiting workflow starts now, not in four seconds
+```
+
+- **One-shot commands should not set a workflow id at all.** Without one, each command already
+ releases the desktop the moment it finishes, and there is nothing to yield.
+- **Multi-step workflows should yield when they finish**, especially when other workflows may be
+ waiting. It costs one fast command and removes a four-second stall from everyone else.
+- It is **idempotent**. Yielding twice, or after the grace has already lapsed, succeeds and reports
+ `{ "released": false }` — that is the normal end of a script, not a failure.
+- It **never releases another workflow's turn**. If somebody else holds the desktop, or nobody does,
+ it is a no-op.
+- It **fails with `ui_turn_busy`** if your own workflow still has a command running or queued —
+ a recording, say. Releasing underneath that would hand the desktop away mid-command, so nothing is
+ released and the running command is unaffected. Wait for it or stop it, then yield again.
+- It requires `WINAPP_UI_WORKFLOW_ID`. Without one it fails with `invalid_arguments`.
+- It takes no app and no selector: it gives back a reservation, not a window, so it still works after
+ the app has closed.
+
+A waiting command is woken by whoever releases the desktop rather than by polling for it, so a queue
+costs almost nothing while it waits and handoff is immediate. Each waiter also rechecks on its own
+occasionally, which is what recovers the desktop when a process is killed and never publishes
+anything: the command at the head of the queue looks every half second, and commands behind it —
+which cannot run before the head does anyway — every few seconds.
+
## Targeting Apps
### By process name
@@ -241,6 +381,8 @@ The default capture path uses **Windows.Graphics.Capture (WGC)**, reading the ac
Use `--capture-screen` when you need to capture popup menus, dropdowns, flyouts, or tooltip overlays that aren't owned by the target window. `--capture-screen` reads from the screen DC and brings the window to the foreground first. Use `--focus` if you just want to foreground the window without switching capture modes (e.g., to ensure the screenshot matches what the user is currently looking at).
+> Because the screen DC captures whatever is actually in front, `--capture-screen` **verifies the target reached the foreground immediately before capturing** and fails with **`foreground_not_target`** if it didn't (focus-stealing prevention, a UAC prompt, or another window activating itself). No image is written in that case — previously the command exited 0 and handed back a picture of the wrong window. `ui record --capture-screen` applies the same check before the first frame.
+
### record
Record a window or element region to an H.264 MP4. By default, recording continues until Ctrl+C or, for redirected stdin, a newline or EOF.
@@ -541,6 +683,15 @@ winapp ui list-windows # all windows (no fi
winapp ui list-windows --show-hidden # include invisible zero-size windows
```
+### yield
+Release this workflow's UI turn early instead of waiting out the four-second idle grace. Requires
+`WINAPP_UI_WORKFLOW_ID`; takes no app and no selector. See
+[Releasing the turn early](#releasing-the-turn-early-winapp-ui-yield).
+```bash
+winapp ui yield
+winapp ui yield --json # {"released": true} — or false when nothing was held
+```
+
## Framework Support
| Framework | inspect | search | invoke | set-value | screenshot |
@@ -596,6 +747,7 @@ for example `MSTest.Windows.UIAutomation`, whose `WindowTest.MainWindow` is a UI
| "No UIA window found" | UIA can't see the process | Use `list-windows` to find the HWND, then `-w` |
| "Window has zero size" | Window is minimized | App will be auto-restored |
| Popup/dropdown not in screenshot | Default capture is per-window and doesn't include unowned overlays | Use `--capture-screen` flag |
+| `foreground_not_target` from `--capture-screen` | Windows refused the activation, so a screen capture would have recorded whatever window is actually in front | Click the target window or close the focus-stealing window and retry, or drop `--capture-screen` |
| `element_not_found` during record | Selector given but no matching element | Re-run `inspect` or `search` to get a fresh selector |
| WGC unavailable during record | WGC capture init failed; no silent fallback | Check GPU/driver; use `--capture-screen` to consent to screen-DC capture |
diff --git a/docs/usage.md b/docs/usage.md
index 69b76e495..7c8877487 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -1469,6 +1469,27 @@ To make this permanent:
[System.Environment]::SetEnvironmentVariable('WINAPP_CLI_UPDATE_CHECK', '0', 'User')
```
+### UI workflow identity
+
+`winapp ui` commands that drive the physical desktop always take cooperative turns, so two workflows
+running at once cannot steal each other's focus or dismiss each other's menus. That arbitration needs
+no setup and cannot be switched off.
+
+What is optional is *continuity*. By default each command is a self-contained one-shot that releases
+the desktop as soon as it finishes. To keep the desktop across several commands, give them all the
+same workflow id:
+
+```pwsh
+$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString()
+```
+
+Use the *same* value for cooperating processes (for example a recording and the clicks it should
+capture) and *different* values for independent workflows. Every command without an id is its own
+one-shot workflow, even when several are launched from one shell, so hosts that start a fresh shell
+per command must inject the same explicit value into each one. The value is opaque, is never treated
+as a credential, and is only ever persisted as a SHA-256 hash. See
+[UI Automation → Coordinating concurrent UI workflows](ui-automation.md#coordinating-concurrent-ui-workflows).
+
### ui
Inspect and interact with running Windows app UIs using UI Automation (UIA).
diff --git a/plugins/winapp/com.github.copilot/agents/winapp.agent.md b/plugins/winapp/com.github.copilot/agents/winapp.agent.md
index eb474e6c2..dd083cd34 100644
--- a/plugins/winapp/com.github.copilot/agents/winapp.agent.md
+++ b/plugins/winapp/com.github.copilot/agents/winapp.agent.md
@@ -75,6 +75,25 @@ Want to inspect or interact with a running app's UI?
├─ Inject pen/stylus ink stroke or tap → winapp ui pen -a --path "10,10 200,200"
└─ List app windows → winapp ui list-windows -a [--show-hidden]
+Driving a UI while other workflows may be running?
+├─ Turn-taking is ALWAYS on — no setup needed, and two agents can never type into each other's
+│ windows
+├─ Set $env:WINAPP_UI_WORKFLOW_ID once per logical workflow and inject the SAME value into every
+│ cooperating call — that is what keeps the desktop across commands. Without it each call is a
+│ one-shot that releases the desktop immediately (each tool call usually gets a fresh shell, and
+│ there is no process-ancestry fallback)
+├─ A workflow with an id keeps the desktop for 4s after its last command; that covers a tight
+│ script but intentionally expires while you reason. It is a fallback, not the way to finish
+├─ Finished a known sequence? → winapp ui yield (hands the desktop over immediately instead of
+│ making a waiting workflow sit out the 4s grace; needs WINAPP_UI_WORKFLOW_ID; safe to repeat)
+├─ `set-value`, `scroll-into-view` and `scroll --direction`/`--to` never take the desktop, so they
+│ stay headless/locked-session friendly — but they DO wait behind another workflow, because they
+│ change what the app shows, and behind an earlier exclusive command of your OWN workflow
+├─ `ui record` shares its turn only with the SAME workflow id — a no-id recording blocks everyone
+│ else for its whole duration
+└─ After a reasoning gap, reopen/re-navigate and re-resolve before acting — another workflow may
+ have used the desktop, so transient UI (menus, flyouts) is gone
+
Building a WinUI 3 UI and need to find the right control or a working sample?
└─ winapp find-ui "" (search WinUI 3 Gallery + Community Toolkit; Reactor is opt-in via --source reactor)
├─ Then fetch full code for a match → winapp find-ui --id
@@ -291,7 +310,7 @@ Building a WinUI 3 UI and need to find the right control or a working sample?
- `ui inspect -a [--depth N] [--interactive] [--hide-disabled] [--hide-offscreen]` — view element tree with semantic slugs and 2-space indentation. `--interactive` filters to invokable elements only (auto-depth 8) — ideal for discovering clickable elements
- `ui search -a [--max N]` — find elements; output shows semantic slugs. Surfaces invokable ancestor for all non-invokable results
- `ui get-property -a [-p ]` — read UIA properties (including ToggleState, Value, IsSelected, ExpandCollapseState)
-- `ui screenshot -a [--output file.png] [--json] [--focus] [--capture-screen]` — capture window as PNG. Default uses Windows.Graphics.Capture (composited surface — preserves rounded corners and works while occluded), with PrintWindow as fallback. Use `--focus` to bring the window to the foreground first; use `--capture-screen` for popup overlays not owned by the target window.
+- `ui screenshot -a [--output file.png] [--json] [--focus] [--capture-screen]` — capture window as PNG. Default uses Windows.Graphics.Capture (composited surface — preserves rounded corners and works while occluded), with PrintWindow as fallback. Use `--focus` to bring the window to the foreground first; use `--capture-screen` for popup overlays not owned by the target window. **`--capture-screen` needs exactly one window** — it reads whatever is in front, and only one window can be. `-w ` selects one: that window's screen region, including any dialog or overlay visibly on top of it. If `-a` matches several top-level or owned windows there is no such selection and it fails with `invalid_arguments` before capturing; run `winapp ui list-windows -a ` and retry with `-w `. If a capture reports `foreground_not_target`, the window could not be brought to the front — do the same thing: list the windows and target one with `-w `.
- `ui record -a [--output file.mp4] [--duration-sec ] [--fps ] [--max-edge ] [--frames] [--capture-screen] [--json]` — record window or element region to an H.264 MP4 using Windows Graphics Capture + Media Foundation. Default is 0 — records until stopped (Ctrl+C interactively, or a newline/EOF on stdin for programmatic callers); use `--duration-sec N` for a timed run. Add `--frames` to retain timestamped JPEGs, `frames.ndjson`, and `manifest.json` under `.frames`. JSON results include `elapsedMs`, `achievedFps`, `cadenceRatio`, `stopReason`, optional `frameArtifacts`, and the capture `mode` (`"wgc"`, `"screen"`, or `"printwindow"`).
- `ui invoke -a ` — activate element by slug or text search. Auto-walks to invokable ancestor for non-invokable elements.
- `ui hover -a [--dwell-time ]` — move mouse to element center to trigger tooltips, flyouts, and hover states. Use with `ui screenshot --capture-screen` to capture the result.
@@ -306,6 +325,7 @@ Building a WinUI 3 UI and need to find the right control or a working sample?
- `ui wait-for -a --timeout [--gone] [--value Y] [--property X --value Y]` — wait for element value or property match
- `ui list-windows -a [--show-hidden]` — list windows, popups, and dialogs with HWNDs (untitled zero-size windows hidden by default)
- `ui get-focused -a ` — show the element with keyboard focus
+- `ui yield` — release this workflow's UI turn early instead of waiting out the 4s idle grace. Requires `WINAPP_UI_WORKFLOW_ID`; takes no app or selector. Idempotent, never releases another workflow's turn, and fails with `ui_turn_busy` if your own workflow still has a command running.
## Framework-specific guidance
diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md
index dcac034e1..d40fdc127 100644
--- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md
+++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md
@@ -11,7 +11,66 @@ description: Inspect and interact with running Windows app UIs from the command
## Prerequisites
- For UIA mode (any app): No setup needed — works with any running Windows app
-- For input-injecting verbs (`click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, `send-keys --via send-input`): an **unlocked, interactive desktop** with the target window foregroundable. On a locked/secure desktop they fail fast with `no_interactive_desktop`. The UIA-pattern verbs (`inspect`, `search`, `get-*`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to`, `screenshot`) are headless/locked-session friendly — prefer them in CI.
+- For input-injecting verbs (`click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, `send-keys --via send-input`): an **unlocked, interactive desktop** with the target window foregroundable. On a locked/secure desktop they fail fast with `no_interactive_desktop`. The UIA-pattern verbs (`inspect`, `search`, `get-*`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to`) are headless/locked-session friendly — prefer them in CI.
+- `screenshot` is **not** in that group: it always takes an exclusive turn, so it queues behind other UI workflows, and capture can need a usable interactive desktop — the engine restores the target if it is minimized, and falls back to foregrounding it when frame capture is unavailable or `--capture-screen` is used.
+- `--capture-screen` needs **exactly one window**. `-w ` gives it one: that window's screen region, including anything visibly on top of it. If `-a` matches several top-level or owned windows the command fails with `invalid_arguments` before capturing; run `winapp ui list-windows -a ` and retry with `-w `.
+- **If other UI workflows may run at the same time**, set one workflow id per logical workflow (see below). Nothing breaks without it, but your commands will not be recognized as belonging together.
+
+## Coordinating with other UI workflows
+
+Windows has one foreground window, one keyboard focus, one cursor, and one input stream. `winapp ui`
+therefore makes desktop-driving commands take **cooperative turns** so concurrent workflows cannot
+steal each other's focus or dismiss each other's menus. That is always on. Read-only commands never
+wait.
+
+Keeping the desktop *across* commands is opt-in — without an id, each command is a one-shot that
+releases the desktop the moment it finishes:
+
+```powershell
+# Set once per logical UI workflow — same value for cooperating calls, different values for
+# independent workflows (even from the same agent).
+$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString()
+```
+
+Rules that matter when driving this from an agent:
+
+- **Each tool call usually gets a fresh shell**, and there is no process-ancestry fallback, so
+ commands are grouped ONLY by the id you inject. Without it every call is its own workflow.
+- **A workflow with an id keeps its turn for four seconds** after its last command. That covers
+ back-to-back commands in one script; it deliberately expires while you are reasoning. Treat it as
+ a fallback for when you cannot say you are done — not as the way to finish.
+- **Run `winapp ui yield` when you finish a known sequence.** It hands the desktop over immediately
+ instead of making a waiting workflow sit out a four-second grace nobody needs. Yielding twice, or
+ after the grace lapsed, is a harmless success.
+- **After a reasoning gap, replay your setup.** Another workflow may have used the desktop, so
+ reopen the menu / re-navigate, re-resolve the element, then act. Do not assume transient UI
+ survived.
+- **Prefer one tight script over many round trips** for a known sequence: `winapp ui invoke View -w
+ $hwnd; winapp ui search "Status bar" -w $hwnd; winapp ui click "Status bar" -w $hwnd; winapp ui yield`.
+- **`record` shares the turn with its own workflow**, so same-workflow clicks and typing are captured
+ while it runs — but only if both commands carry the same id. A `record` with no id blocks everyone
+ else for its whole duration.
+- **Ordering is owner affinity, then FIFO.** An active workflow may keep issuing commands ahead of
+ others already waiting; once it yields or its grace expires, waiters are served in arrival order.
+- **Waiting is indefinite and cancellable.** A status line appears after one second; Ctrl+C exits
+ `130` with error code `cancelled` and the command never ran.
+- **There is no hard cap** — a long script, unbounded recording, or failure loop can block other
+ mutating workflows until it finishes or is stopped.
+
+Commands that never wait: `status`, `list-windows`, `inspect`, `search`, `get-*`, `wait-for`.
+Commands that wait for the turn but never take the desktop (headless/locked-session friendly):
+`set-value`, `scroll-into-view`, `scroll --direction`/`--to`, `record`. They mutate the app, so they
+queue behind another workflow. Inside your own workflow they overlap with other shared work — that
+is how `record` captures the `set-value` calls it is recording — but they still wait behind an
+earlier `DesktopExclusive` command of your own workflow, so a `click` followed by a `set-value` runs
+in the order you wrote it.
+Commands that take the desktop exclusively: `invoke`, `click`, `drag`, `hover`, `scroll --wheel`,
+`touch`, `pen`, `focus`, `send-keys`, `screenshot`.
+
+```powershell
+# Finish a workflow deliberately rather than leaving the desktop reserved for four more seconds.
+winapp ui yield
+```
## Common patterns
@@ -121,7 +180,7 @@ winapp ui record -a myapp --capture-screen --duration-sec 5 --output with-popups
- Default `--duration-sec 0` records until Ctrl+C, a newline, or EOF on redirected stdin.
- `--frames` writes `.frames` with a manifest, NDJSON index, and changed JPEGs. It supports 1-30 fps and `--max-edge` 64-4096 (default 1280), with a 1 GiB cap. Use `elapsedMs` to bound transitions.
- With `--frames`, existing MP4 and frame paths are not replaced. On partial failure, use the reported preserved path and `recoveryHint`.
-- `--capture-screen` captures from the screen DC so overlays and popups are included; the window is brought to the foreground first. When WGC is unavailable and `--capture-screen` is not passed, the CLI returns an error — re-run with `--capture-screen` to consent to screen-DC capture.
+- `--capture-screen` captures from the screen DC so overlays and popups are included; the window is brought to the foreground first. When WGC is unavailable and `--capture-screen` is not passed, the CLI returns an error — re-run with `--capture-screen` to consent to screen-DC capture. Because the screen DC captures whatever is genuinely in front, the target's foreground is **verified immediately before capture**; if activation was refused the command fails with `foreground_not_target` and writes nothing rather than returning an image of the wrong window.
- Providing a selector that doesn't match any element fails immediately with `element_not_found` (rather than silently recording the whole window).
- `--json` writes the final result to stdout and one JSON event per line to stderr.
@@ -321,6 +380,7 @@ Full schemas with examples: `references/ui-json-envelope.md`.
| "does not support any invoke pattern" | Element can't be invoked | The error shows the invokable ancestor slug if one exists — use that |
| "No UIA window found" | UIA can't see the window | Use `list-windows` to find HWND, then `-w` |
| Popup not in screenshot | Default capture path doesn't include unowned overlays | Use `--capture-screen` flag |
+| `foreground_not_target` from `--capture-screen` | Windows refused the activation (focus-stealing prevention, UAC prompt, another window activating itself), so a screen capture would have recorded the wrong window | Click the target window, close the window that stole focus, then retry — or drop `--capture-screen` to capture the window directly |
| `element_not_found` during record | Selector given but element not in tree | Re-run `inspect` or `search` to get a fresh selector |
| `ambiguous_selector` during record | Plain-text selector matched multiple elements | Use a slug from the suggestions in the error message, or from `inspect` output |
| WGC unavailable during record | WGC capture init failed; no silent fallback | Check GPU/driver; use `--capture-screen` to explicitly request screen DC capture |
diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md
index a8a0cf866..6a0fa1988 100644
--- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md
+++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md
@@ -85,3 +85,72 @@ like a label inside a button).
The internal `id`, `parentSelector`, and `windowHandle` fields are
**scrubbed** from results — both at the top level and inside any nested
`invokableAncestor`. Don't depend on them; use `selector` as the handle.
+
+## Error envelope
+
+Every `winapp ui` command writes errors to **stderr** as:
+
+```json
+{
+ "error": {
+ "code": "element_not_found",
+ "message": "…",
+ "selector": "btn-save-c3d4",
+ "details": "…",
+ "recoveryHint": "…"
+ }
+}
+```
+
+Only `code` and `message` are always present; the rest are omitted when
+they do not apply.
+
+### Desktop coordination
+
+Concurrent `winapp ui` workflows take cooperative turns on the shared
+desktop (see the skill's coordination section). Four additional codes can
+appear:
+
+| `code` | Meaning |
+|---|---|
+| `invalid_ui_workflow_id` | `WINAPP_UI_WORKFLOW_ID` is set but empty/whitespace or longer than 256 characters. Fails before any UI side effect. |
+| `desktop_coordination_unavailable` | Coordination state could not be read, published, or safely rebuilt — including state written by a newer `winapp`. Mutating commands fail closed rather than acting uncoordinated. |
+| `queue_capacity_exceeded` | 64 commands from other workflows are already waiting for the desktop. Counts live foreign waiters, so entries left by commands that exited or were killed do not occupy a slot. |
+| `ui_turn_busy` | `ui yield` was run while this same workflow still has a command running or queued, so its turn is not idle. Nothing was released, and the running command is unaffected. Distinct from `invalid_arguments` (the request was well formed) and from `desktop_coordination_unavailable` (coordination is working — this is a valid request at an unsafe moment). Carries a `recoveryHint`: wait for or stop this workflow's other `winapp ui` commands — typically a `record` started with the same `WINAPP_UI_WORKFLOW_ID` — then retry `yield`. |
+| `cancelled` | Native Ctrl+C while the command was still waiting for its turn. The command never ran, so it has no UI side effects. Exit code **130**. |
+
+`ui yield` also emits the command-level `invalid_arguments` when `WINAPP_UI_WORKFLOW_ID` is not set
+at all — deliberately not `invalid_ui_workflow_id`, which means the variable is present but
+malformed. On success it writes `{ "released": true }`, or `{ "released": false }` when this workflow
+held nothing to release (both exit **0**).
+
+An npm `AbortSignal` is a different contract: Node force-terminates the child,
+so there is usually no envelope and no exit code 130 — the wrapper rejects with
+an `AbortError` instead, and UI side effects may already have happened if the
+abort landed after the command acquired the desktop.
+
+`cancelled` — and optionally the other coordination errors — carries an
+additive `coordination` object:
+
+```json
+{
+ "error": {
+ "code": "cancelled",
+ "message": "UI turn wait was cancelled.",
+ "coordination": {
+ "waitedMs": 1234,
+ "queuePosition": 2
+ }
+ }
+}
+```
+
+`waitedMs` is always present for a cancellation while queued.
+`queuePosition` is one-based among live waiters and is **omitted** when it
+cannot be computed reliably — including while a command waits behind its
+own workflow's earlier command. Workflow identities are never exposed, in
+raw or hashed form.
+
+Cancelling *after* the command acquired its turn keeps that command's
+existing behavior; for example Ctrl+C during `ui record` still finalizes
+the recording and returns its normal successful result.
diff --git a/samples/winui-app/README.md b/samples/winui-app/README.md
index 6a7ab4e08..9f60145ca 100644
--- a/samples/winui-app/README.md
+++ b/samples/winui-app/README.md
@@ -50,6 +50,11 @@ winapp run .\bin\x64\Debug\net10.0-windows10.0.26100.0\win-x64 --detach --json
## Testing with winapp ui
```powershell
+# Optional: set once per logical UI workflow so these commands are treated as one workflow and can
+# overlap with each other. Collision arbitration against other workflows is always on either way;
+# this only adds continuity across commands. Unset, each command runs as a standalone one-shot.
+$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString()
+
# Inspect the UI tree
winapp ui inspect -a winui-app
diff --git a/scripts/test-ui-coordination.ps1 b/scripts/test-ui-coordination.ps1
new file mode 100644
index 000000000..3468d14d9
--- /dev/null
+++ b/scripts/test-ui-coordination.ps1
@@ -0,0 +1,78 @@
+<#
+.SYNOPSIS
+Runs the gated cooperative desktop-turn tests and proves they actually ran.
+
+.DESCRIPTION
+The UI coordination suites (issue #764 §18.2/§18.3) drive real winapp.exe child processes against a
+real foreground window, so they are gated behind WINAPP_UI_MULTIPROCESS_TESTS and stay off the
+canonical build. This script sets that gate and runs them on an interactive machine or CI lane where
+the published CLI binaries already exist.
+
+The gate itself is the reason this is a script rather than a plain `dotnet run`. When the gate is
+unset, or the published-binary lookup regresses, every test reports Inconclusive — and the run still
+exits 0. Trusting the exit code would mean a green build that verified nothing, which is exactly what
+happened before these suites were wired into CI at all. So the result is asserted against the TRX:
+zero tests is a broken filter, any skip is a broken gate, and either fails the run.
+
+.PARAMETER ResultsDirectory
+Where to write the TRX. Default: artifacts/TestResults/ui-coordination under the repo root.
+
+.PARAMETER Configuration
+Build configuration for the test project. Default: Debug.
+
+.PARAMETER Filter
+Test filter. Default: the multiprocess and real-app coordination suites.
+
+.EXAMPLE
+.\test-ui-coordination.ps1
+Run every gated coordination test and fail on zero-matched, skipped, or failing tests.
+
+.EXAMPLE
+.\test-ui-coordination.ps1 -Filter "FullyQualifiedName~InteractiveDesktopRealAppTests"
+Run only the real-app suite.
+#>
+
+param(
+ [string]$ResultsDirectory,
+ [string]$Configuration = 'Debug',
+ [string]$Filter = 'FullyQualifiedName~InteractiveDesktopMultiprocessTests|FullyQualifiedName~InteractiveDesktopRealAppTests'
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$repoRoot = (Resolve-Path "$PSScriptRoot\..").Path
+$testProject = Join-Path $repoRoot 'src\winapp-CLI\WinApp.Cli.Tests\WinApp.Cli.Tests.csproj'
+
+if (-not $ResultsDirectory) {
+ $ResultsDirectory = Join-Path $repoRoot 'artifacts\TestResults\ui-coordination'
+}
+
+New-Item -ItemType Directory -Path $ResultsDirectory -Force | Out-Null
+
+# The suites resolve the published winapp.exe for the current architecture themselves; this only has
+# to turn them on. Scoped to the child process so an interactive shell is not left gated.
+$env:WINAPP_UI_MULTIPROCESS_TESTS = '1'
+
+dotnet run --project $testProject -c $Configuration `
+ --results-directory $ResultsDirectory --report-trx --report-trx-filename ui-coordination.trx `
+ --filter $Filter
+$testExit = $LASTEXITCODE
+
+$trx = Get-ChildItem -Path $ResultsDirectory -Filter *.trx -Recurse | Sort-Object LastWriteTime | Select-Object -Last 1
+if (-not $trx) { throw "No TRX produced: the UI coordination tests did not run." }
+
+[xml]$doc = Get-Content $trx.FullName
+$counters = $doc.TestRun.ResultSummary.Counters
+$total = [int]$counters.total
+$passed = [int]$counters.passed
+$failed = [int]$counters.failed
+# MSTest reports Assert.Inconclusive (the gate's skip path) under notExecuted.
+$skipped = [int]$counters.notExecuted + [int]$counters.inconclusive
+Write-Host "UI coordination tests: total=$total passed=$passed failed=$failed skipped=$skipped"
+
+# Asserted dynamically rather than pinned to today's count, so adding coverage does not fail the
+# build while a filter that stops matching still does.
+if ($total -eq 0) { throw "The UI coordination filter matched no tests — it no longer selects the gated suites." }
+if ($skipped -gt 0) { throw "$skipped UI coordination test(s) skipped; the gate must run them here, not skip them." }
+if ($failed -gt 0 -or $testExit -ne 0) { throw "UI coordination tests failed (exit $testExit)." }
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DesktopPrimitiveGuardTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DesktopPrimitiveGuardTests.cs
new file mode 100644
index 000000000..1fcf3f4ef
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/DesktopPrimitiveGuardTests.cs
@@ -0,0 +1,92 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Text.RegularExpressions;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Guards the coordination invariant that every desktop-wide foreground or window-state change goes
+/// through IDesktopForegroundService (issue #764).
+///
+///
+/// The value of coordination is that a reviewer can answer "what can move the foreground?" by reading
+/// one file. A single direct PInvoke.SetForegroundWindow added later — even one that happens to
+/// sit inside a desktop section today — silently reopens that question, and the next refactor may move
+/// it outside the section. Asserting over the source keeps the invariant true by construction rather
+/// than by convention.
+///
+[TestClass]
+public class DesktopPrimitiveGuardTests
+{
+ /// The one file allowed to call the primitives directly.
+ private const string ForegroundServiceFile = "IDesktopForegroundService.cs";
+
+ private static readonly Regex s_directPrimitiveCall = new(
+ @"PInvoke\s*\.\s*(SetForegroundWindow|ShowWindow)\s*\(",
+ RegexOptions.Compiled);
+
+ [TestMethod]
+ public void NoProductionCodeCallsForegroundPrimitivesOutsideTheForegroundService()
+ {
+ var productionRoot = FindProductionRoot();
+ var offenders = new List();
+
+ foreach (var file in Directory.EnumerateFiles(productionRoot, "*.cs", SearchOption.AllDirectories))
+ {
+ // Skip build output: CsWin32 emits the P/Invoke declarations themselves under obj\.
+ var relative = Path.GetRelativePath(productionRoot, file);
+ if (relative.Contains($"obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)
+ || relative.Contains($"bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(Path.GetFileName(file), ForegroundServiceFile, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var text = File.ReadAllText(file);
+ foreach (Match match in s_directPrimitiveCall.Matches(text))
+ {
+ var line = text.Take(match.Index).Count(c => c == '\n') + 1;
+ offenders.Add($"{relative}({line}): {match.Value.Trim()}");
+ }
+ }
+
+ Assert.AreEqual(
+ 0,
+ offenders.Count,
+ "Desktop foreground and window-state changes must go through IDesktopForegroundService so "
+ + "they are coordinated and reviewable in one place. Offending call sites:\n"
+ + string.Join("\n", offenders));
+ }
+
+ [TestMethod]
+ public void TheGuardActuallyMatchesADirectPrimitiveCall()
+ {
+ // A source-scanning assertion is worthless if its pattern silently stops matching, so pin the
+ // pattern against the exact shapes it is meant to catch.
+ Assert.IsTrue(s_directPrimitiveCall.IsMatch("Windows.Win32.PInvoke.SetForegroundWindow(hwnd);"));
+ Assert.IsTrue(s_directPrimitiveCall.IsMatch("PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_RESTORE);"));
+ Assert.IsTrue(s_directPrimitiveCall.IsMatch("PInvoke . SetForegroundWindow ("));
+ Assert.IsFalse(s_directPrimitiveCall.IsMatch("_desktopForeground.RequestForeground(hwnd);"));
+ Assert.IsFalse(s_directPrimitiveCall.IsMatch("// mentions SetForegroundWindow in prose"));
+ }
+
+ /// Locates src\winapp-CLI\WinApp.Cli by walking up from the test binaries.
+ private static string FindProductionRoot()
+ {
+ var directory = AppContext.BaseDirectory;
+ for (var i = 0; i < 8 && directory is not null; i++)
+ {
+ var candidate = Path.Combine(directory, "src", "winapp-CLI", "WinApp.Cli");
+ if (Directory.Exists(candidate))
+ {
+ return candidate;
+ }
+
+ directory = Path.GetDirectoryName(directory.TrimEnd(Path.DirectorySeparatorChar));
+ }
+
+ throw new AssertInconclusiveException(
+ "The WinApp.Cli production sources were not found, so the desktop-primitive guard could not run.");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs
new file mode 100644
index 000000000..3f272ca9e
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs
@@ -0,0 +1,195 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.Logging.Abstractions;
+using WinApp.Cli.Helpers;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Post-wait target validation (, issue #764 spec §10.5).
+///
+///
+/// The check has to satisfy two things that pull in opposite directions: refuse a handle Windows has
+/// recycled onto an unrelated process during a queue wait, while still allowing the cross-process
+/// windows an app legitimately owns. UiAutomationService.GetAllAppWindows deliberately
+/// discovers common-item file pickers and system dialogs — they run in another process yet are part of
+/// the app's UI, and elements found on them carry that foreign HWND — so a plain PID equality check
+/// made every one of those targets unreachable after a wait.
+///
+[TestClass]
+public class DesktopTargetValidationTests : IDisposable
+{
+ private const int SessionPid = 1234;
+ private const int ForeignPid = 5678;
+
+ private FakeSystemUiQuery _systemQuery = null!;
+ private StringWriter _errorOut = null!;
+
+ public void Dispose()
+ {
+ _errorOut?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _systemQuery = new FakeSystemUiQuery();
+ _errorOut = new StringWriter();
+ }
+
+ private bool Confirm(long hwnd, int expectedPid = SessionPid)
+ => DesktopTargetValidation.TryConfirmTargetWindow(
+ _systemQuery, hwnd, expectedPid, NullLogger.Instance, json: true, "click", _errorOut);
+
+ private void AssertStaleElementEmitted()
+ => StringAssert.Contains(_errorOut.ToString(), "\"code\":\"stale_element\"");
+
+ // ------------------------------------------------------------------ the straightforward cases
+
+ [TestMethod]
+ public void DirectSamePidWindowIsAccepted()
+ {
+ _systemQuery.ProcessIdByHwnd[100] = SessionPid;
+
+ Assert.IsTrue(Confirm(100));
+ Assert.AreEqual(string.Empty, _errorOut.ToString(), "an accepted target emits no error");
+ }
+
+ [TestMethod]
+ public void BareCoordinateTargetIsAccepted()
+ {
+ // hwnd 0 means "no window to confirm" — the foreground guard is the gate for coordinates.
+ Assert.IsTrue(Confirm(0));
+ }
+
+ [TestMethod]
+ public void ClosedWindowIsRejected()
+ {
+ _systemQuery.ProcessIdByHwnd[100] = 0;
+
+ Assert.IsFalse(Confirm(100));
+ AssertStaleElementEmitted();
+ }
+
+ [TestMethod]
+ public void ForeignPidWithNoOwnerIsRejected()
+ {
+ // The recycled-handle case: the original window exited and Windows handed its handle to an
+ // unrelated top-level window. No owner chain, so nothing associates it with the session.
+ _systemQuery.ProcessIdByHwnd[100] = ForeignPid;
+
+ Assert.IsFalse(Confirm(100));
+ AssertStaleElementEmitted();
+ }
+
+ // -------------------------------------------------------- legitimate cross-process owned windows
+
+ [TestMethod]
+ public void CrossProcessWindowOwnedByTheExpectedProcessIsAccepted()
+ {
+ // Exactly what a common-item file picker looks like: a window in another process whose
+ // GW_OWNER is one of the session's own windows. Discovery checks exactly this one hop; the
+ // validator additionally follows longer chains, which the nested-dialog case below covers.
+ _systemQuery.ProcessIdByHwnd[100] = ForeignPid;
+ _systemQuery.WindowOwnerByHwnd[100] = 200;
+ _systemQuery.ProcessIdByHwnd[200] = SessionPid;
+
+ Assert.IsTrue(Confirm(100));
+ Assert.AreEqual(string.Empty, _errorOut.ToString());
+ }
+
+ [TestMethod]
+ public void CrossProcessWindowWhoseOwnerDiedIsRejected()
+ {
+ // The owning app exited during the queue wait; its window handle now resolves to no process.
+ // The picker may still be on screen, but it is no longer the session's UI.
+ _systemQuery.ProcessIdByHwnd[100] = ForeignPid;
+ _systemQuery.WindowOwnerByHwnd[100] = 200;
+ _systemQuery.ProcessIdByHwnd[200] = 0;
+
+ Assert.IsFalse(Confirm(100));
+ AssertStaleElementEmitted();
+ }
+
+ [TestMethod]
+ public void CrossProcessWindowWhoseOwnerHandleWasRecycledIsRejected()
+ {
+ // The owner handle is live but now belongs to a *different* process than the one resolved.
+ // Accepting on "the owner exists" alone would launder an unrelated window into the session.
+ _systemQuery.ProcessIdByHwnd[100] = ForeignPid;
+ _systemQuery.WindowOwnerByHwnd[100] = 200;
+ _systemQuery.ProcessIdByHwnd[200] = 9999;
+
+ Assert.IsFalse(Confirm(100));
+ AssertStaleElementEmitted();
+ }
+
+ [TestMethod]
+ public void OwnerChainIsFollowedThroughAnIntermediateWindow()
+ {
+ // A dialog owned by a dialog owned by the app window.
+ _systemQuery.ProcessIdByHwnd[100] = ForeignPid;
+ _systemQuery.WindowOwnerByHwnd[100] = 200;
+ _systemQuery.ProcessIdByHwnd[200] = ForeignPid;
+ _systemQuery.WindowOwnerByHwnd[200] = 300;
+ _systemQuery.ProcessIdByHwnd[300] = SessionPid;
+
+ Assert.IsTrue(Confirm(100));
+ }
+
+ [TestMethod]
+ public void OwnerCycleIsRejectedRatherThanLoopingForever()
+ {
+ // A torn or hostile window tree must terminate the walk, not hang the command.
+ _systemQuery.ProcessIdByHwnd[100] = ForeignPid;
+ _systemQuery.WindowOwnerByHwnd[100] = 200;
+ _systemQuery.ProcessIdByHwnd[200] = ForeignPid;
+ _systemQuery.WindowOwnerByHwnd[200] = 100;
+
+ Assert.IsFalse(Confirm(100));
+ AssertStaleElementEmitted();
+ }
+
+ [TestMethod]
+ public void UnknownExpectedProcessSkipsTheOwnershipCheck()
+ {
+ // expectedProcessId <= 0 means the caller had no PID to compare against; the existing contract
+ // is to allow through rather than invent a rejection.
+ _systemQuery.ProcessIdByHwnd[100] = ForeignPid;
+
+ Assert.IsTrue(Confirm(100, expectedPid: 0));
+ }
+
+ // -------------------------------------------------- the silent verdict used for batched handles
+
+ ///
+ /// The multi-window screenshot composite validates every handle it is about to capture. Each one
+ /// needs a verdict without writing a top-level error envelope, because a single unreachable window
+ /// is recorded against that window and the remaining ones are still captured — so the classifier
+ /// has to be reusable separately from the reporting.
+ ///
+ [TestMethod]
+ public void ClassifierAgreesWithTheReportingCheckOnEveryOutcome()
+ {
+ _systemQuery.ProcessIdByHwnd[100] = SessionPid; // straightforward match
+ _systemQuery.ProcessIdByHwnd[300] = ForeignPid; // recycled onto another process
+ _systemQuery.ProcessIdByHwnd[400] = ForeignPid; // owned dialog
+ _systemQuery.WindowOwnerByHwnd[400] = 100;
+ // 200 is deliberately unmapped: GetProcessIdForWindow reports 0, i.e. destroyed.
+
+ Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Valid, Classify(100));
+ Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Gone, Classify(200));
+ Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Recycled, Classify(300));
+ Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Valid, Classify(400));
+
+ // A zero handle is a bare-coordinate target, which has no window to confirm.
+ Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Valid, Classify(0));
+
+ Assert.AreEqual(string.Empty, _errorOut.ToString(), "classifying must never emit an error envelope");
+ }
+
+ private DesktopTargetValidation.TargetWindowState Classify(long hwnd, int expectedPid = SessionPid)
+ => DesktopTargetValidation.ClassifyTargetWindow(_systemQuery, hwnd, expectedPid);
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs
new file mode 100644
index 000000000..4ac1af493
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Helpers;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Records foreground and restore requests instead of performing them, so tests can assert that a
+/// command asks for the foreground only inside a desktop-sensitive section, and never before its
+/// foreground validation has run (spec §13).
+///
+internal sealed class FakeDesktopForegroundService : IDesktopForegroundService
+{
+ /// Window handles passed to , in order.
+ public List ForegroundRequests { get; } = [];
+
+ /// Window handles passed to , in order.
+ public List RestoreRequests { get; } = [];
+
+ /// Handles this fake reports as minimized, so a test can drive the restore path.
+ public HashSet MinimizedWindows { get; } = [];
+
+ ///
+ /// Reports every window as minimized, so a test can force the restore path without having to know
+ /// which handle the fake session happens to resolve to.
+ ///
+ public bool AllWindowsMinimized { get; set; }
+
+ public void RequestForeground(long hwnd) => ForegroundRequests.Add(hwnd);
+
+ ///
+ /// Whether reports success. Defaults to so the
+ /// ordinary "activation took" path is what tests get without opting in; set to
+ /// to simulate Windows refusing the activation (focus-stealing prevention,
+ /// a decoy window grabbing focus) and prove a screen capture refuses instead of recording the
+ /// wrong window.
+ ///
+ public bool ForegroundRequestSucceeds { get; set; } = true;
+
+ /// Window handles passed to , in order.
+ public List ForegroundChecks { get; } = [];
+
+ public bool IsForeground(long hwnd)
+ {
+ ForegroundChecks.Add(hwnd);
+ return ForegroundRequestSucceeds;
+ }
+
+ public bool IsMinimized(long hwnd) => AllWindowsMinimized || MinimizedWindows.Contains(hwnd);
+
+ public void Restore(long hwnd) => RestoreRequests.Add(hwnd);
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs
new file mode 100644
index 000000000..deddab673
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs
@@ -0,0 +1,129 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.CommandLine;
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Pass-through for command handler tests.
+///
+///
+/// Command tests build the real DI graph, so without this fake every ui test would open lock
+/// files under the developer's real %LOCALAPPDATA% and queue against their live desktop
+/// workflows. It also records what each command asked for, which is how the tests assert coordination
+/// mode, desktop-section placement, and — critically — that a command rejected in preflight never
+/// touched coordination at all.
+///
+internal sealed class FakeInteractiveDesktopLock : IInteractiveDesktopLock
+{
+ /// Every coordinated run, in order. Empty means preflight rejected before coordination.
+ public List<(UiTurnMode Mode, string Operation)> Runs { get; } = [];
+
+ /// How many times a body entered a desktop-sensitive section.
+ public int DesktopSectionEnters { get; private set; }
+
+ /// How many desktop sections are open right now; tests assert this around observable calls.
+ public int OpenDesktopSections { get; private set; }
+
+ /// Set to throw from , to cover coordination failures.
+ public UiCoordinationException? ThrowOnRun { get; set; }
+
+ /// Every ui yield that reached coordination, in order.
+ public List YieldCalls { get; } = [];
+
+ /// What reports. Defaults to a successful release.
+ public UiYieldResult YieldResult { get; set; } = UiYieldResult.Released;
+
+ /// Set to throw from , to cover coordination failures.
+ public UiCoordinationException? ThrowOnYield { get; set; }
+
+ public UiYieldResult ReleaseIdleTurn(CancellationToken cancellationToken)
+ {
+ if (ThrowOnYield is { } failure)
+ {
+ throw failure;
+ }
+
+ YieldCalls.Add(YieldResult);
+ return YieldResult;
+ }
+
+ /// Milliseconds reported as queue wait, so output/telemetry paths can be exercised.
+ public long WaitedMs { get; set; }
+
+ ///
+ /// Whether the most recent coordinated body threw instead of returning an exit code.
+ ///
+ ///
+ /// This is the exact signal the real coordinator uses to decide whether to renew the owner's idle
+ /// grace: a body that RETURNS is a completed command and renews, a body that THROWS did not produce
+ /// a result and must not. System.CommandLine flattens a propagating cancellation to exit code 1 —
+ /// the same code the old swallow path returned — so the exit code cannot distinguish the two and
+ /// tests have to observe the boundary itself.
+ ///
+ public bool LastBodyThrew { get; private set; }
+
+ /// The exception the most recent coordinated body threw, if any.
+ public Exception? LastBodyException { get; private set; }
+
+ public async Task RunCoordinatedAsync(
+ UiTurnMode mode,
+ string operation,
+ ParseResult parseResult,
+ Func> body,
+ CancellationToken cancellationToken)
+ {
+ Runs.Add((mode, operation));
+
+ if (ThrowOnRun is { } failure)
+ {
+ throw failure;
+ }
+
+ LastBodyThrew = false;
+ LastBodyException = null;
+ try
+ {
+ return await body(new FakeTurn(this, mode), cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ LastBodyThrew = true;
+ LastBodyException = ex;
+ throw;
+ }
+ }
+
+ private sealed class FakeTurn(FakeInteractiveDesktopLock owner, UiTurnMode mode) : IUiTurn
+ {
+ public UiTurnMode Mode { get; private set; } = mode;
+
+ public long WaitedMs => owner.WaitedMs;
+
+ public Task EnterAsync(CancellationToken cancellationToken)
+ {
+ owner.DesktopSectionEnters++;
+ owner.OpenDesktopSections++;
+ return Task.FromResult(new FakeSection(owner));
+ }
+
+ }
+
+ private sealed class FakeSection(FakeInteractiveDesktopLock owner) : IAsyncDisposable
+ {
+ private bool _disposed;
+
+ public ValueTask DisposeAsync()
+ {
+ if (!_disposed)
+ {
+ _disposed = true;
+ owner.OpenDesktopSections--;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeParticipantSignals.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeParticipantSignals.cs
new file mode 100644
index 000000000..89fbd4644
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeParticipantSignals.cs
@@ -0,0 +1,107 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// In-memory stand-in for the named-event wake-up channels, so queue behavior can be tested without
+/// real cross-process events.
+///
+///
+/// Models the two properties the real primitive is chosen for: a signal delivered before anyone waits
+/// stays latched and is consumed by the next wait, and one signal releases exactly one wait. It also
+/// records every timeout a waiter asked for, which is how the recovery-deadline schedule is asserted
+/// without sleeping through it.
+///
+internal sealed class FakeParticipantSignals : IParticipantSignals
+{
+ private readonly Dictionary<(int Pid, long Start), FakeSignal> _channels = [];
+ private readonly Lock _sync = new();
+
+ /// Every participant that was woken, in order, including repeats.
+ public List<(int Pid, long StartTicksUtc)> Signalled { get; } = [];
+
+ /// Timeouts requested by waits, in order, so a test can assert the recovery schedule.
+ public List RequestedTimeouts { get; } = [];
+
+ /// Signals delivered to participants that never opened a channel.
+ public List<(int Pid, long StartTicksUtc)> SignalledWithoutChannel { get; } = [];
+
+ public IParticipantSignal Create(int processId, long startTicksUtc)
+ {
+ lock (_sync)
+ {
+ var signal = new FakeSignal(this);
+ _channels[(processId, startTicksUtc)] = signal;
+ return signal;
+ }
+ }
+
+ public void Signal(int processId, long startTicksUtc)
+ {
+ FakeSignal? channel;
+ lock (_sync)
+ {
+ Signalled.Add((processId, startTicksUtc));
+ if (!_channels.TryGetValue((processId, startTicksUtc), out channel))
+ {
+ SignalledWithoutChannel.Add((processId, startTicksUtc));
+ return;
+ }
+ }
+
+ channel.Set();
+ }
+
+ /// How many times was woken.
+ public int SignalCountFor(UiParticipantIdentity participant)
+ {
+ lock (_sync)
+ {
+ return Signalled.Count(s => s.Pid == participant.ProcessId && s.StartTicksUtc == participant.StartTicksUtc);
+ }
+ }
+
+ /// Wakes a participant directly, standing in for another process's promotion.
+ public void SignalDirect(UiParticipantIdentity participant)
+ => Signal(participant.ProcessId, participant.StartTicksUtc);
+
+ internal sealed class FakeSignal(FakeParticipantSignals owner) : IParticipantSignal
+ {
+ // Capacity one: the latch either holds a pending wake-up or it does not, exactly like an
+ // auto-reset event. Releasing twice cannot bank two wake-ups.
+ private readonly SemaphoreSlim _latch = new(0, 1);
+
+ public bool Disposed { get; private set; }
+
+ public void Set()
+ {
+ try
+ {
+ _latch.Release();
+ }
+ catch (SemaphoreFullException)
+ {
+ // Already latched; a second signal before anyone waits is not two wake-ups.
+ }
+ }
+
+ public async Task WaitAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ lock (owner._sync)
+ {
+ owner.RequestedTimeouts.Add(timeout);
+ }
+
+ return await _latch.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
+ }
+
+ public void Dispose()
+ {
+ Disposed = true;
+ _latch.Dispose();
+ }
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiRecordingService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiRecordingService.cs
index 99b4f6350..5d02b43d1 100644
--- a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiRecordingService.cs
+++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiRecordingService.cs
@@ -23,6 +23,14 @@ internal sealed class FakeUiRecordingService : IUiRecordingService
public bool? RecordingStartedFrameArtifactsActiveOverride { get; set; }
+ public Action? BeforeRecordingStarted { get; set; }
+
+ public Action? AfterRecordingStarted { get; set; }
+
+ public bool InvokeRecordingStartedTwice { get; set; }
+
+ public Task? WaitAfterRecordingStarted { get; set; }
+
public async Task RecordAsync(UiTarget uiTarget, string? elementId, RecordOptions options, CancellationToken ct, Action? onRecordingStarted = null)
{
LastRecordOptions = options;
@@ -54,9 +62,19 @@ await File.WriteAllTextAsync(
TotalBytes = 64,
};
}
- onRecordingStarted?.Invoke(
- RecordingStartedFrameArtifactsActiveOverride
- ?? (options.FramesDirectory is not null));
+ BeforeRecordingStarted?.Invoke();
+ var frameArtifactsActive = RecordingStartedFrameArtifactsActiveOverride
+ ?? (options.FramesDirectory is not null);
+ onRecordingStarted?.Invoke(frameArtifactsActive);
+ if (InvokeRecordingStartedTwice)
+ {
+ onRecordingStarted?.Invoke(frameArtifactsActive);
+ }
+ AfterRecordingStarted?.Invoke();
+ if (WaitAfterRecordingStarted is { } wait)
+ {
+ await wait.ConfigureAwait(false);
+ }
if (RecordExceptionAfterStarted is not null)
{
throw RecordExceptionAfterStarted;
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs
new file mode 100644
index 000000000..ea7164a0e
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs
@@ -0,0 +1,292 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// How a queued command sleeps and what wakes it, over the real store, leases and file locks.
+///
+///
+/// Waiting used to be a 50-75 ms poll, so every one of these questions had the same boring answer:
+/// the command would notice within a poll. Now a waiter can sleep for seconds, so what wakes it — and
+/// what happens when nothing does — is the behavior worth pinning down.
+///
+public partial class InteractiveDesktopLockTests
+{
+ /// Waits for a condition without pinning the schedule to wall-clock luck.
+ private static async Task EventuallyAsync(Func condition, int timeoutMs = 15_000)
+ {
+ var deadline = Environment.TickCount64 + timeoutMs;
+ while (Environment.TickCount64 < deadline)
+ {
+ if (condition())
+ {
+ return true;
+ }
+
+ await Task.Delay(20);
+ }
+
+ return condition();
+ }
+
+ // ----------------------------------------------------------------------------- what wakes a waiter
+
+ [TestMethod]
+ public async Task CompletingACommandWakesTheWaiterItPromoted()
+ {
+ // The ordinary handoff. Without this the waiter would sit until a recovery deadline, which is
+ // the difference between a script that flows and one that stutters for half a second per step.
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) =>
+ {
+ started.SetResult();
+ return Task.FromResult(0);
+ }, CancellationToken.None);
+
+ Assert.IsTrue(
+ await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0),
+ "the command should be waiting on its signal rather than polling");
+ Assert.IsFalse(started.Task.IsCompleted);
+
+ // Releasing the foreign lease is what a crash looks like; the waiter recovers it itself.
+ foreignLease.Dispose();
+
+ Assert.AreEqual(0, await queued);
+ Assert.IsTrue(started.Task.IsCompleted);
+ }
+
+ [TestMethod]
+ public async Task AWakeUpDeliveredBeforeTheWaitIsNotLost()
+ {
+ // The race the auto-reset event exists to close: a promoter can publish and signal in the window
+ // between a waiter reading state and actually waiting. A latch that only released waiters
+ // already parked would strand that command until its backstop fired.
+ var signal = _signals.Create(4242, 99);
+
+ _signals.Signal(4242, 99);
+
+ var woken = await signal.WaitAsync(TimeSpan.FromMilliseconds(50), CancellationToken.None);
+ Assert.IsTrue(woken, "a signal delivered before the wait must still release it");
+
+ // ...and it is consumed, not sticky: one signal is one wake-up.
+ var again = await signal.WaitAsync(TimeSpan.FromMilliseconds(50), CancellationToken.None);
+ Assert.IsFalse(again, "the latch must auto-reset so a stale signal cannot wake the next wait too");
+ }
+
+ [TestMethod]
+ public async Task AStaleWakeUpCannotStartACommandThatIsStillQueued()
+ {
+ // Signals are hints, not authority. A duplicate or misdirected wake must cost one state read
+ // and nothing else — never a command running out of turn on somebody else's desktop.
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) =>
+ {
+ started.SetResult();
+ return Task.FromResult(0);
+ }, CancellationToken.None);
+
+ Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0));
+
+ var self = new UiParticipantIdentity(Environment.ProcessId, ProcessStartTicks(), "ui click");
+ for (var i = 0; i < 5; i++)
+ {
+ _signals.SignalDirect(self);
+ await Task.Delay(30);
+ }
+
+ Assert.IsFalse(
+ started.Task.IsCompleted,
+ "the turn is still held, so no number of wake-ups may let this command run");
+
+ foreignLease.Dispose();
+ Assert.AreEqual(0, await queued);
+ }
+
+ // --------------------------------------------------------------------------- the recovery schedule
+
+ [TestMethod]
+ public async Task TheQueueHeadRechecksBrisklyBecauseNobodyMayBeAliveToWakeIt()
+ {
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ using var cts = new CancellationTokenSource();
+ var queued = RunAsyncWithToken(
+ UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0), cts.Token);
+
+ Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0));
+
+ var firstTimeout = _signals.RequestedTimeouts[0];
+ Assert.AreEqual(
+ InteractiveDesktopLock.HeadRecoveryMs,
+ firstTimeout.TotalMilliseconds,
+ $"the only waiter is the head and must take the short interval; it asked for {firstTimeout}");
+
+ await cts.CancelAsync();
+ Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued);
+ }
+
+ [TestMethod]
+ public async Task AWaiterBehindTheHeadSleepsOnTheLongBackstop()
+ {
+ // Someone else is the head, so this command's turn cannot come before theirs — and they will be
+ // woken and will wake this one in turn. Its own deadline only covers a promoter that died
+ // between publishing and signalling, which is why it is ten times longer.
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+ using var headLease = QueueForeignWaiterAhead(foreignPid: 515151, foreignStart: 123123);
+
+ using var cts = new CancellationTokenSource();
+ var queued = RunAsyncWithToken(
+ UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0), cts.Token);
+
+ Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0));
+
+ var firstTimeout = _signals.RequestedTimeouts[0];
+ Assert.AreEqual(
+ InteractiveDesktopLock.DeepRecoveryMs,
+ firstTimeout.TotalMilliseconds,
+ $"a waiter behind the head must not recheck at head cadence; it asked for {firstTimeout}");
+
+ await cts.CancelAsync();
+ Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued);
+ }
+
+ [TestMethod]
+ public async Task AQuietWaiterDoesNotWakeUpRepeatedlyToSayNothing()
+ {
+ // The measurable half of the change: with no status line to print and no signal to consume, a
+ // deep waiter should be asleep, not spinning. Under the old poll this window held ~30 wake-ups.
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+ using var headLease = QueueForeignWaiterAhead(foreignPid: 626262, foreignStart: 456456);
+
+ using var cts = new CancellationTokenSource();
+ var queued = RunAsyncWithToken(
+ UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0), cts.Token);
+
+ Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0));
+ await Task.Delay(1_500);
+
+ Assert.IsLessThanOrEqualTo(
+ 2,
+ _signals.RequestedTimeouts.Count,
+ $"a quiet deep waiter must sleep rather than poll; it woke {_signals.RequestedTimeouts.Count} times in 1.5s");
+
+ await cts.CancelAsync();
+ Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued);
+ }
+
+ [TestMethod]
+ public async Task CancellingAQueuedCommandWakesTheParticipantsItLeavesBehind()
+ {
+ // Cancellation removes an entry, which can be exactly what lets someone else through, so the
+ // teardown path has to wake people just like an ordinary completion does.
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ using var cts = new CancellationTokenSource();
+ var queued = RunAsyncWithToken(
+ UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0), cts.Token);
+
+ Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0));
+ await cts.CancelAsync();
+ Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var state = _store.Read().State!;
+ Assert.IsFalse(
+ state.Waiters.Any(w => w.Pid == Environment.ProcessId),
+ "a cancelled command must leave no queue entry behind for others to prune");
+ }
+
+ [TestMethod]
+ public async Task AQuietWaiterStillRecordsQueueGrowthThatHappensAfterItRegisters()
+ {
+ // The telemetry summary reports the deepest queue a command ever saw. Sampling that next to the
+ // status line made it depend on whether anyone was watching: under --json and --quiet no status
+ // line is ever due, so the depth froze at whatever it was when the command registered and every
+ // command that piled up behind it went unrecorded — exactly the runs where queue depth is worth
+ // knowing.
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ UiCoordinationTelemetryScope.Begin();
+
+ using var cts = new CancellationTokenSource();
+ var queued = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ (_, _) => Task.FromResult(0), cts.Token);
+
+ Assert.IsTrue(
+ await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0),
+ "the command must be waiting before the queue grows behind it");
+
+ // Three more waiters arrive after this command registered, so a depth captured only at
+ // registration would report one rather than four.
+ using var second = QueueForeignWaiterAhead(foreignPid: 717171, foreignStart: 717);
+ using var third = QueueForeignWaiterAhead(foreignPid: 727272, foreignStart: 727);
+ using var fourth = QueueForeignWaiterAhead(foreignPid: 737373, foreignStart: 737);
+
+ // Wake it so it takes another look; the wake is a hint, and the look is what samples the depth.
+ _signals.SignalDirect(new UiParticipantIdentity(
+ Environment.ProcessId, new ProcessInspector().CurrentProcessStartTicksUtc, "ui click"));
+
+ Assert.IsTrue(
+ await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 1),
+ "the waiter must have looked again after the queue grew");
+
+ await cts.CancelAsync();
+ Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued);
+
+ var summary = UiCoordinationTelemetryScope.Current;
+ Assert.IsNotNull(summary);
+ Assert.IsGreaterThanOrEqualTo(
+ 4,
+ summary!.QueueDepth,
+ $"a quiet waiter must still observe the queue growing behind it; it recorded {summary.QueueDepth}");
+ }
+
+ ///
+ /// Adds a live foreign waiter ahead of this process, so the test process is deliberately not the
+ /// head of the queue.
+ ///
+ private FileStream QueueForeignWaiterAhead(int foreignPid, long foreignStart)
+ {
+ _paths.EnsureDirectories();
+ var leaseStream = new FileStream(
+ _paths.LeasePath(foreignPid, foreignStart),
+ FileMode.Create,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ FileOptions.DeleteOnClose);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var state = _store.Read().State!;
+
+ // Tickets must be unique and at least 1, and nextTicket must stay above every ticket in use —
+ // the store rejects state that breaks either, because ambiguous tickets would make both the
+ // forward barrier and global FIFO order undefined. Allocating properly is also what puts this
+ // waiter genuinely ahead of the command the test starts next.
+ var ticket = state.NextTicket;
+ state.NextTicket = ticket + 1;
+ state.Waiters.Add(new WaiterEntry
+ {
+ Ticket = ticket,
+ OwnerKey = "yet-another-workflow",
+ OwnerKind = UiOwnerKind.Workflow,
+ Pid = foreignPid,
+ ProcessStartTicksUtc = foreignStart,
+ Operation = "ui click",
+ Mode = UiTurnMode.DesktopExclusive,
+ });
+ _store.Publish(state);
+
+ return leaseStream;
+ }
+
+ private static long ProcessStartTicks() => new ProcessInspector().CurrentProcessStartTicksUtc;
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.Yield.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.Yield.cs
new file mode 100644
index 000000000..d7377bdc0
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.Yield.cs
@@ -0,0 +1,370 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Explicit early release (winapp ui yield) and the turn-sharing behaviour of background-safe
+/// mutations, over the real store, leases and file locks.
+///
+public partial class InteractiveDesktopLockTests
+{
+ // ------------------------------------------------- background-safe mutations take a shared turn
+
+ [TestMethod]
+ public async Task ABackgroundSafeMutationWaitsWhileAnotherWorkflowOwnsTheDesktop()
+ {
+ // set-value and scroll --direction never take active.lock, but they do change what the app
+ // shows, so they must not run underneath another workflow's click.
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ using var cts = new CancellationTokenSource();
+ var ran = false;
+ var queued = RunAsyncWithToken(UiTurnMode.TurnShared, "ui set-value", (_, _) =>
+ {
+ ran = true;
+ return Task.FromResult(0);
+ }, cts.Token);
+
+ await Task.Delay(250);
+ Assert.IsFalse(ran, "a background-safe mutation must wait behind a foreign workflow's turn");
+
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ Assert.AreEqual(1, _store.Read().State!.Waiters.Count,
+ "it must be recorded as a global waiter, not run detached");
+ }
+
+ await cts.CancelAsync();
+ await queued;
+ }
+
+ [TestMethod]
+ public async Task ABackgroundSafeMutationOverlapsWithItsOwnWorkflowsRecording()
+ {
+ // The point of TurnShared rather than DesktopExclusive: a recording and the set-value it is
+ // recording belong to one workflow and must run at the same time. The recording is seeded as a
+ // live foreign process under this same workflow, because participant identity is
+ // (pid, processStartTicks) and one test process cannot hold two leases.
+ using var recordingLease = OccupyTurnWithThisWorkflow(UiTurnMode.TurnShared, "ui record");
+
+ var mutationRan = false;
+ var mutation = RunAsync(UiTurnMode.TurnShared, "ui set-value", (_, _) =>
+ {
+ mutationRan = true;
+ return Task.FromResult(0);
+ });
+
+ Assert.AreEqual(0, await mutation.WaitAsync(TimeSpan.FromSeconds(5)),
+ "the same workflow's mutation must not wait for its own recording to finish");
+ Assert.IsTrue(mutationRan);
+ }
+
+ [TestMethod]
+ public async Task ABackgroundSafeMutationStillWaitsBehindItsOwnWorkflowsExclusiveBarrier()
+ {
+ // Same workflow, but an exclusive command is already holding the barrier. Sharing the turn does
+ // not mean ignoring the barrier: the mutation must queue behind it like any later command.
+ using var clickLease = OccupyTurnWithThisWorkflow(UiTurnMode.DesktopExclusive, "ui click");
+
+ using var cts = new CancellationTokenSource();
+ var ran = false;
+ var queued = RunAsyncWithToken(UiTurnMode.TurnShared, "ui set-value", (_, _) =>
+ {
+ ran = true;
+ return Task.FromResult(0);
+ }, cts.Token);
+
+ await Task.Delay(250);
+ Assert.IsFalse(ran, "an exclusive command of the same workflow is still a forward barrier");
+
+ await cts.CancelAsync();
+ await queued;
+ }
+
+ // ------------------------------------------------------------------------ ui yield: rejection
+
+ [TestMethod]
+ public void Yield_WithNoWorkflowId_IsRejectedWithoutReadingState()
+ {
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, null);
+
+ Assert.AreEqual(UiYieldResult.NotAWorkflow, _coordinator.ReleaseIdleTurn(CancellationToken.None));
+ Assert.IsFalse(File.Exists(_paths.StatePath),
+ "an anonymous caller holds no turn by construction, so state must not be touched to prove it");
+ }
+
+ [TestMethod]
+ public void Yield_WithABlankWorkflowId_StillReportsTheMalformedValue()
+ {
+ // Absence and malformation are different mistakes. An unset variable means "I am a one-shot"
+ // and is rejected locally as invalid_arguments; a variable that expanded to "" is a scripting
+ // bug, and must keep reporting invalid_ui_workflow_id through the ordinary resolver path.
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, " ");
+
+ var ex = Assert.ThrowsExactly(
+ () => _coordinator.ReleaseIdleTurn(CancellationToken.None));
+ Assert.AreEqual(UiCoordinationErrorCodes.InvalidWorkflowId, ex.Code);
+ }
+
+ // ------------------------------------------------------------------------- ui yield: releasing
+
+ [TestMethod]
+ public async Task Yield_ReleasesThisWorkflowsIdleTurn()
+ {
+ // A completed command leaves the turn owned and idle for the grace; yield ends that early.
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0));
+
+ using (var beforeLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var before = _store.Read().State!;
+ Assert.IsNotNull(before.Owner, "the workflow keeps the turn through its idle grace");
+ Assert.AreEqual(0, before.OwnerCommands.Count);
+ }
+
+ Assert.AreEqual(UiYieldResult.Released, _coordinator.ReleaseIdleTurn(CancellationToken.None));
+
+ using var afterLock = _store.AcquireStateLock(CancellationToken.None);
+ var after = _store.Read().State!;
+ Assert.IsNull(after.Owner, "the turn must be free immediately, not at the end of the grace");
+ Assert.AreEqual(0, after.IdleExpiresTick64);
+ }
+
+ [TestMethod]
+ public async Task Yield_PromotesAndWakesTheWaitingWorkflow()
+ {
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0));
+
+ // A foreign workflow queued behind this one's idle grace.
+ const int WaitingPid = 515151;
+ const long WaitingStart = 424242424;
+ using var waitingLease = OpenForeignLease(WaitingPid, WaitingStart);
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var state = _store.Read().State!;
+ state.Waiters.Add(new WaiterEntry
+ {
+ Ticket = state.AllocateTicket(),
+ OwnerKey = "a-waiting-workflow",
+ OwnerKind = UiOwnerKind.Workflow,
+ Pid = WaitingPid,
+ ProcessStartTicksUtc = WaitingStart,
+ Operation = "ui click",
+ Mode = UiTurnMode.DesktopExclusive,
+ });
+ _store.Publish(state);
+ }
+
+ _signals.Signalled.Clear();
+ Assert.AreEqual(UiYieldResult.Released, _coordinator.ReleaseIdleTurn(CancellationToken.None));
+
+ using var afterLock = _store.AcquireStateLock(CancellationToken.None);
+ var after = _store.Read().State!;
+ Assert.AreEqual("a-waiting-workflow", after.Owner!.Key, "the waiter must take the turn");
+ Assert.AreEqual(1, after.OwnerCommands.Count);
+ Assert.AreEqual(UiCommandStatus.Running, after.OwnerCommands[0].Status);
+ CollectionAssert.Contains(
+ _signals.Signalled, (WaitingPid, WaitingStart),
+ "the promoted waiter must be woken rather than left to time out");
+ }
+
+ [TestMethod]
+ public async Task Yield_Twice_IsIdempotent()
+ {
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0));
+
+ Assert.AreEqual(UiYieldResult.Released, _coordinator.ReleaseIdleTurn(CancellationToken.None));
+ Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None),
+ "yielding an already-released turn is the normal end of a script, not a failure");
+ }
+
+ // ---------------------------------------------------------------------------- ui yield: no-ops
+
+ [TestMethod]
+ public void Yield_WhenNobodyOwnsTheTurn_IsANoOp()
+ => Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None));
+
+ [TestMethod]
+ public void Yield_NeverReleasesAnotherWorkflowsTurn()
+ {
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None));
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var state = _store.Read().State!;
+ Assert.AreEqual("some-other-workflow", state.Owner!.Key, "the other workflow must keep its turn");
+ Assert.AreEqual(1, state.OwnerCommands.Count, "and keep its running command");
+ }
+
+ [TestMethod]
+ public void Yield_PublishesPruningEvenWhenItReleasesNothing()
+ {
+ // A released:false branch that still changed state. The dead waiter's lease was never opened,
+ // so normalization prunes it; if yield returned without publishing, that pruning would be
+ // silently discarded and every later reader would keep paying for the phantom entry.
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ state.NextTicket = 2;
+ state.Waiters.Add(new WaiterEntry
+ {
+ Ticket = 1,
+ OwnerKey = "a-dead-workflow",
+ OwnerKind = UiOwnerKind.Workflow,
+ Pid = 818181,
+ ProcessStartTicksUtc = 777888999,
+ Operation = "ui click",
+ Mode = UiTurnMode.DesktopExclusive,
+ });
+ _store.Publish(state);
+ }
+
+ Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None));
+
+ using var afterLock = _store.AcquireStateLock(CancellationToken.None);
+ var after = _store.Read().State!;
+ Assert.AreEqual(0, after.Waiters.Count, "the pruning yield performed must be published, not dropped");
+ Assert.IsNull(after.Owner, "a dead waiter must not be promoted into ownership");
+ }
+
+ // ------------------------------------------------------------------------------ ui yield: busy
+
+ [TestMethod]
+ public async Task Yield_WhileThisWorkflowStillHasALiveCommand_RefusesAsBusy()
+ {
+ var commandStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseCommand = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var running = RunAsync(UiTurnMode.TurnShared, "ui record", async (_, _) =>
+ {
+ commandStarted.SetResult();
+ await releaseCommand.Task;
+ return 0;
+ });
+
+ await commandStarted.Task;
+
+ Assert.AreEqual(UiYieldResult.Busy, _coordinator.ReleaseIdleTurn(CancellationToken.None),
+ "the turn is not idle while this workflow is still driving it");
+
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var state = _store.Read().State!;
+ Assert.IsNotNull(state.Owner, "a refused yield must leave the turn exactly as it was");
+ Assert.AreEqual(1, state.OwnerCommands.Count);
+ }
+
+ releaseCommand.SetResult();
+ Assert.AreEqual(0, await running);
+ }
+
+ // --------------------------------------------------------------------- ui yield: normalization
+
+ [TestMethod]
+ public void Yield_NormalizesACrashedOwnerAndPromotesTheNextWorkflow()
+ {
+ // The dead owner's lease is never opened, so it reads as crashed. Yield's own transaction has
+ // to publish that normalization even though this workflow had nothing of its own to release.
+ const int DeadPid = 606060;
+ const long DeadStart = 111222333;
+ const int WaitingPid = 707070;
+ const long WaitingStart = 444555666;
+
+ using var waitingLease = OpenForeignLease(WaitingPid, WaitingStart);
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ state.TurnId = 1;
+ state.NextTicket = 3;
+ state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = "a-crashed-workflow" };
+ state.OwnerCommands.Add(new OwnerCommandEntry
+ {
+ Ticket = 1,
+ Pid = DeadPid,
+ ProcessStartTicksUtc = DeadStart,
+ Operation = "ui click",
+ Mode = UiTurnMode.DesktopExclusive,
+ Status = UiCommandStatus.Running,
+ });
+ state.Waiters.Add(new WaiterEntry
+ {
+ Ticket = 2,
+ OwnerKey = "a-waiting-workflow",
+ OwnerKind = UiOwnerKind.Workflow,
+ Pid = WaitingPid,
+ ProcessStartTicksUtc = WaitingStart,
+ Operation = "ui click",
+ Mode = UiTurnMode.DesktopExclusive,
+ });
+ _store.Publish(state);
+ }
+
+ _signals.Signalled.Clear();
+ Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None),
+ "the yielding workflow held nothing — the crashed owner did");
+
+ using var afterLock = _store.AcquireStateLock(CancellationToken.None);
+ var after = _store.Read().State!;
+ Assert.AreEqual("a-waiting-workflow", after.Owner!.Key,
+ "yield's normalization must publish the recovery it just performed");
+ CollectionAssert.Contains(_signals.Signalled, (WaitingPid, WaitingStart));
+ }
+
+ ///
+ /// Holds a foreign participant's lease FileShare.None, which is exactly what the liveness
+ /// probe sees for a real second process.
+ ///
+ private FileStream OpenForeignLease(int pid, long startTicksUtc)
+ {
+ _paths.EnsureDirectories();
+ return new FileStream(
+ _paths.LeasePath(pid, startTicksUtc),
+ FileMode.Create,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ FileOptions.DeleteOnClose);
+ }
+
+ ///
+ /// Publishes state in which this workflow already holds the turn through a live command
+ /// running in another process, and holds that command's lease so it reads as live.
+ ///
+ ///
+ /// Participant identity is (pid, processStartTicks), so one test process cannot hold two
+ /// leases and cannot run two coordinated commands at once. Seeding the first command is how the
+ /// same-workflow overlap rules — which are about two processes sharing a workflow id —
+ /// stay testable in process.
+ ///
+ private FileStream OccupyTurnWithThisWorkflow(
+ UiTurnMode mode, string operation, int foreignPid = 313131, long foreignStart = 191919191)
+ {
+ var leaseStream = OpenForeignLease(foreignPid, foreignStart);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var state = InteractiveDesktopState.CreateFresh();
+ state.TurnId = 1;
+ state.NextTicket = 2;
+ state.Owner = new OwnerRecord
+ {
+ Kind = UiOwnerKind.Workflow,
+ // The same key this test process resolves, so the seeded command really is "us".
+ Key = new UiOwnerResolver().Resolve().Key,
+ };
+ state.OwnerCommands.Add(new OwnerCommandEntry
+ {
+ Ticket = 1,
+ Pid = foreignPid,
+ ProcessStartTicksUtc = foreignStart,
+ Operation = operation,
+ Mode = mode,
+ Status = UiCommandStatus.Running,
+ });
+ _store.Publish(state);
+
+ return leaseStream;
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs
new file mode 100644
index 000000000..980a4656b
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs
@@ -0,0 +1,660 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.CommandLine;
+using Microsoft.Extensions.Logging.Abstractions;
+using Spectre.Console;
+using Spectre.Console.Testing;
+using WinApp.Cli.Commands;
+using WinApp.Cli.Helpers;
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// End-to-end coverage of over the real store, leases and file
+/// locks (issue #764): admission, the forward barrier, and the desktop-section contract.
+///
+[TestClass]
+[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY and WINAPP_UI_WORKFLOW_ID are process-wide.
+public partial class InteractiveDesktopLockTests
+{
+ private string _lockDirectory = null!;
+ private string? _previousLockOverride;
+ private string? _previousOwnerId;
+ private InteractiveDesktopPaths _paths = null!;
+ private ParticipantRegistry _participants = null!;
+ private InteractiveDesktopStateStore _store = null!;
+ private InteractiveDesktopLock _coordinator = null!;
+ private FakeParticipantSignals _signals = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-lock-svc-{Guid.NewGuid():N}");
+ _previousLockOverride = Environment.GetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable);
+ _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable);
+
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory);
+ // A stable explicit owner keeps these tests independent of the test host's parent process.
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, "interactive-desktop-lock-tests");
+
+ var inspector = new ProcessInspector();
+ _signals = new FakeParticipantSignals();
+ _paths = new InteractiveDesktopPaths(inspector);
+ _participants = new ParticipantRegistry(_paths, inspector, NullLogger.Instance);
+ _store = new InteractiveDesktopStateStore(
+ _paths, _participants, new TickCountClock(), NullLogger.Instance);
+ _coordinator = new InteractiveDesktopLock(
+ _store,
+ _paths,
+ _participants,
+ new UiOwnerResolver(),
+ inspector,
+ new TickCountClock(),
+ new FakePollDelay(),
+ _signals,
+ new TestConsole(),
+ NullLogger.Instance);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride);
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, _previousOwnerId);
+ UiCoordinationTelemetryScope.Clear();
+
+ try
+ {
+ if (Directory.Exists(_lockDirectory))
+ {
+ Directory.Delete(_lockDirectory, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // A leaked temp directory must never fail a test.
+ }
+ }
+
+ private static ParseResult Parse()
+ {
+ var command = new Command("probe");
+ command.Options.Add(WinAppRootCommand.JsonOption);
+ command.Options.Add(WinAppRootCommand.QuietOption);
+ command.Options.Add(WinAppRootCommand.VerboseOption);
+ return command.Parse(["--quiet"]);
+ }
+
+ private Task RunAsync(UiTurnMode mode, string operation, Func> body)
+ => _coordinator.RunCoordinatedAsync(mode, operation, Parse(), body, CancellationToken.None);
+
+ // ------------------------------------------------------------------------------- admission
+
+ [TestMethod]
+ public async Task ObserveOnAFreeDesktop_RunsDetachedAndLeavesNoState()
+ {
+ var ran = false;
+ var exit = await RunAsync(UiTurnMode.Observe, "ui inspect", (turn, _) =>
+ {
+ ran = true;
+ Assert.AreEqual(UiTurnMode.Observe, turn.Mode);
+ return Task.FromResult(0);
+ });
+
+ Assert.AreEqual(0, exit);
+ Assert.IsTrue(ran);
+ Assert.IsFalse(_participants.AnyLiveParticipant(), "a detached observation opens no lease");
+ }
+
+ [TestMethod]
+ public async Task DesktopExclusive_ClaimsTheTurnAndReleasesItOnCompletion()
+ {
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) =>
+ {
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var state = _store.Read().State!;
+ Assert.IsNotNull(state.Owner, "the command must own the turn while it runs");
+ Assert.AreEqual(1, state.OwnerCommands.Count);
+ Assert.AreEqual(UiCommandStatus.Running, state.OwnerCommands[0].Status);
+ return Task.FromResult(0);
+ });
+
+ using var afterLock = _store.AcquireStateLock(CancellationToken.None);
+ var after = _store.Read().State!;
+ Assert.AreEqual(0, after.OwnerCommands.Count, "the entry is removed on completion");
+ Assert.IsFalse(_participants.AnyLiveParticipant(), "the lease closes after the entry is removed");
+ }
+
+ [TestMethod]
+ public async Task NonZeroExitStillCountsAsACompletedCommand()
+ {
+ // Spec §10.6: a command that ran and returned a failing code still renews the grace, because the
+ // workflow is alive and its next command is probably a retry.
+ var exit = await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(3));
+
+ Assert.AreEqual(3, exit, "the command's own exit code must reach the caller unchanged");
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ Assert.IsNotNull(_store.Read().State!.Owner, "the turn stays reserved for the idle grace");
+ }
+
+ [TestMethod]
+ public async Task CoordinationSummaryIsPublishedForTelemetry()
+ {
+ // Program opens this scope before invoking a command; do the same so the summary the coordinator
+ // writes deep inside the invocation is visible here.
+ UiCoordinationTelemetryScope.Begin();
+
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0));
+
+ var summary = UiCoordinationTelemetryScope.Current;
+ Assert.IsNotNull(summary);
+ Assert.AreEqual(UiOwnerKind.Workflow, summary!.IdentitySource);
+ Assert.AreEqual(UiTurnMode.DesktopExclusive, summary.Mode);
+ Assert.AreEqual(UiCoordinationOutcome.Completed, summary.Outcome);
+
+ // Buckets only — an exact wait duration could correlate a user's workflow timing across events.
+ StringAssert.Matches(summary.WaitBucket, new System.Text.RegularExpressions.Regex(@"^\d+(-\d+|\+)?$"));
+ }
+
+ [TestMethod]
+ public async Task CoordinationSummary_TurnAgeMeasuresTheHeldTurnNotTheQueueWait()
+ {
+ // Regression: turn age used to be read from the queue-wait stopwatch, so it duplicated the wait
+ // bucket and was always zero for a command that acquired the desktop immediately.
+ Assert.AreEqual(0, await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0)));
+
+ // Still inside the four-second grace, so the second command is a continuation of the same turn.
+ await Task.Delay(150);
+
+ UiCoordinationTelemetryScope.Begin();
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0));
+
+ var summary = UiCoordinationTelemetryScope.Current;
+ Assert.IsNotNull(summary);
+ Assert.AreEqual(UiTurnAction.Continuation, summary!.TurnAction);
+ Assert.AreEqual(0, summary.WaitedMs, "nothing was queued, so this command never waited");
+ Assert.IsTrue(summary.TurnAgeMs >= 100,
+ $"the turn had been held for over 150 ms; reported age was {summary.TurnAgeMs} ms");
+ }
+
+ [TestMethod]
+ public async Task CoordinationSummary_DetachedObservationReportsNoTurnAge()
+ {
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+ UiCoordinationTelemetryScope.Begin();
+
+ await RunAsync(UiTurnMode.Observe, "ui inspect", (_, _) => Task.FromResult(0));
+
+ var summary = UiCoordinationTelemetryScope.Current;
+ Assert.IsNotNull(summary);
+ Assert.AreEqual(UiTurnAction.Detached, summary!.TurnAction);
+ Assert.AreEqual(0, summary.TurnAgeMs, "a detached observation holds no turn, so it has no age");
+ }
+
+ [TestMethod]
+ public async Task CoordinationSummary_ReportsHandoffAfterIdleWhenTakingOverALapsedTurn()
+ {
+ // Spec §16 advertises `handoff-after-idle`; this proves the value is actually reachable.
+ using (var foreignLease = OccupyTurnWithAnotherOwner())
+ {
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var state = _store.Read().State!;
+ state.OwnerCommands.Clear();
+ state.IdleExpiresTick64 = 1; // already elapsed
+ _store.Publish(state);
+ }
+
+ UiCoordinationTelemetryScope.Begin();
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0));
+
+ var summary = UiCoordinationTelemetryScope.Current;
+ Assert.IsNotNull(summary);
+ Assert.AreEqual(UiTurnAction.HandoffAfterIdle, summary!.TurnAction);
+ }
+
+ [TestMethod]
+ public async Task CoordinationSummary_StateWithoutATurnStartTickReportsNoTurnAge()
+ {
+ // A state file written before the turn-start field existed deserializes it as 0, which is also
+ // the "no owner" sentinel. Measuring an age from it would report the machine's uptime.
+ _paths.EnsureDirectories();
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ state.TurnId = 7;
+ state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = "some-other-workflow" };
+ state.TurnStartedTick64 = 0;
+ state.IdleExpiresTick64 = 1; // already elapsed, so this command takes the turn over
+ _store.Publish(state);
+ }
+
+ UiCoordinationTelemetryScope.Begin();
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0));
+
+ var summary = UiCoordinationTelemetryScope.Current;
+ Assert.IsNotNull(summary);
+ Assert.IsTrue(summary!.TurnAgeMs < 60_000,
+ $"a freshly claimed turn cannot be minutes old; reported {summary.TurnAgeMs} ms");
+ }
+
+ [TestMethod]
+ public async Task CancellationAfterAcquisitionEmitsTheContractAndDoesNotRenewTheGrace()
+ {
+ // A command cancelled after it took the turn produced nothing, so it must not renew the owner's
+ // idle grace — and it must report the documented `cancelled` contract rather than escaping as an
+ // "unexpected error". This is the coordinator half of the `ui record` pre-start fix: the command
+ // propagates the cancellation instead of swallowing it, and this is what receives it.
+ Assert.AreEqual(0, await RunAsync(UiTurnMode.TurnShared, "ui record", (_, _) => Task.FromResult(0)));
+ var deadlineBefore = ReadOwnerDeadline();
+
+ await Task.Delay(50);
+
+ var errorWriter = new StringWriter();
+ var parseResult = ParseWithWriter(errorWriter);
+
+ using var cts = new CancellationTokenSource();
+ var exitCode = await _coordinator.RunCoordinatedAsync(
+ UiTurnMode.TurnShared, "ui record", parseResult,
+ async (_, token) =>
+ {
+ await cts.CancelAsync();
+ token.ThrowIfCancellationRequested();
+ return 0;
+ },
+ cts.Token);
+
+ Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, exitCode,
+ "a cancelled command reports 130, not an internal error");
+ StringAssert.Contains(errorWriter.ToString(), "\"code\":\"cancelled\"");
+ Assert.AreEqual(0, ReadOwnerDeadline(),
+ "a command that produced nothing must not renew the owner's idle grace");
+ }
+
+ [TestMethod]
+ public async Task ABodyThatFailsCoordinationDoesNotRenewTheOwnersGrace()
+ {
+ // A coordination fault raised inside the body (for example active.lock I/O failing while a
+ // recording opens its desktop section) must not look like a completed command.
+ Assert.AreEqual(0, await RunAsync(UiTurnMode.TurnShared, "ui record", (_, _) => Task.FromResult(0)));
+ var deadlineBefore = ReadOwnerDeadline();
+
+ await Task.Delay(50);
+
+ var ex = await Assert.ThrowsExactlyAsync(() =>
+ RunAsync(UiTurnMode.TurnShared, "ui record", (_, _) => throw new UiCoordinationException(
+ UiCoordinationErrorCodes.Unavailable, "active.lock could not be opened")));
+
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+ Assert.AreEqual(0, ReadOwnerDeadline(),
+ "a command that never ran must not renew the owner's idle grace");
+ }
+
+ // --------------------------------------------------------------------------- desktop sections
+
+ [TestMethod]
+ public async Task DesktopSection_TakesAndReleasesTheActiveLock()
+ {
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", async (turn, ct) =>
+ {
+ Assert.IsTrue(_store.IsActiveLockFree(), "active.lock is not held before the section opens");
+
+ await using (await turn.EnterAsync(ct))
+ {
+ Assert.IsFalse(_store.IsActiveLockFree(), "the section must hold active.lock");
+ }
+
+ Assert.IsTrue(_store.IsActiveLockFree(), "the section must release active.lock on dispose");
+ return 0;
+ });
+ }
+
+ [TestMethod]
+ public async Task DesktopSection_IsNotHeldAcrossTheWholeCommandBody()
+ {
+ // The turn wraps execution, but active.lock must not: output formatting, encoding and file
+ // publication would otherwise block every other workflow for the whole command.
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) =>
+ {
+ Assert.IsTrue(_store.IsActiveLockFree());
+ return Task.FromResult(0);
+ });
+ }
+
+ [TestMethod]
+ public async Task DesktopSection_SequentialEntersEachTakeTheLockAfresh()
+ {
+ // Screenshot and record open one section per restore/foreground/live-screen moment.
+ await RunAsync(UiTurnMode.TurnShared, "ui screenshot", async (turn, ct) =>
+ {
+ for (var i = 0; i < 3; i++)
+ {
+ await using (await turn.EnterAsync(ct))
+ {
+ Assert.IsFalse(_store.IsActiveLockFree());
+ }
+
+ Assert.IsTrue(_store.IsActiveLockFree());
+ }
+
+ return 0;
+ });
+ }
+
+ [TestMethod]
+ public async Task DesktopSection_ConcurrentEntersInOneCommandSerializeInsteadOfOverlapping()
+ {
+ // Regression guard: an earlier refcount design let a second concurrent task see depth > 0 and
+ // skip active.lock entirely, running its desktop work with no cross-process protection.
+ var concurrent = 0;
+ var maxConcurrent = 0;
+ var gate = new object();
+
+ await RunAsync(UiTurnMode.DesktopExclusive, "ui click", async (turn, ct) =>
+ {
+ var tasks = Enumerable.Range(0, 8).Select(async _ =>
+ {
+ await using (await turn.EnterAsync(ct))
+ {
+ lock (gate)
+ {
+ concurrent++;
+ maxConcurrent = Math.Max(maxConcurrent, concurrent);
+ }
+
+ Assert.IsFalse(_store.IsActiveLockFree(),
+ "every section must actually hold active.lock, not just believe it does");
+ await Task.Delay(5, ct);
+
+ lock (gate)
+ {
+ concurrent--;
+ }
+ }
+ }).ToArray();
+
+ await Task.WhenAll(tasks);
+ return 0;
+ });
+
+ Assert.AreEqual(1, maxConcurrent, "unrelated concurrent enters in one command must serialize");
+ Assert.IsTrue(_store.IsActiveLockFree());
+ }
+
+ [TestMethod]
+ public async Task DesktopSection_IsReleasedWhenTheBodyThrows()
+ {
+ await Assert.ThrowsExactlyAsync(() =>
+ RunAsync(UiTurnMode.DesktopExclusive, "ui click", async (turn, ct) =>
+ {
+ await turn.EnterAsync(ct);
+ throw new InvalidOperationException("boom (test)");
+ }));
+
+ Assert.IsTrue(_store.IsActiveLockFree(),
+ "a leaked section must never leave active.lock held for the rest of the process");
+ }
+
+ // ------------------------------------------------------------------------ coordination failure
+
+ [TestMethod]
+ public async Task UnknownNewerSchemaFailsParticipatingCommandsAndAllowsDetachedObservations()
+ {
+ _paths.EnsureDirectories();
+ File.WriteAllText(
+ _paths.StatePath,
+ """{"version":99,"turnId":1,"nextTicket":2,"ownerCommands":[],"waiters":[]}""");
+
+ var ex = await Assert.ThrowsExactlyAsync(() =>
+ RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0)));
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+
+ // Observations may continue: they never claim a turn and never write state.
+ var observed = false;
+ var exit = await RunAsync(UiTurnMode.Observe, "ui inspect", (_, _) =>
+ {
+ observed = true;
+ return Task.FromResult(0);
+ });
+
+ Assert.AreEqual(0, exit);
+ Assert.IsTrue(observed);
+ }
+
+ [TestMethod]
+ public async Task InvalidExplicitWorkflowIdFailsBeforeAnyUiSideEffect()
+ {
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, " ");
+
+ var ran = false;
+ var ex = await Assert.ThrowsExactlyAsync(() =>
+ RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) =>
+ {
+ ran = true;
+ return Task.FromResult(0);
+ }));
+
+ Assert.AreEqual(UiCoordinationErrorCodes.InvalidWorkflowId, ex.Code);
+ Assert.IsFalse(ran, "the command body must never run with an unusable owner identity");
+ Assert.IsFalse(_participants.AnyLiveParticipant(), "no lease may be left behind");
+ }
+
+ // -------------------------------------------------------- queueing behind another live owner
+
+ ///
+ /// Publishes state in which a different owner holds the turn, and holds that owner's
+ /// participant lease so it reads as live.
+ ///
+ ///
+ /// Participant identity is (pid, processStartTicks), so a single process cannot legitimately
+ /// act as two owners. Holding the foreign lease file FileShare.None reproduces exactly what
+ /// the liveness probe observes for a real second process — a locked lease — without needing one,
+ /// which keeps the cancellation contract deterministically testable. (Genuine cross-process
+ /// behavior is covered separately by the multiprocess lane.)
+ ///
+ private FileStream OccupyTurnWithAnotherOwner(int foreignPid = 424242, long foreignStart = 987654321)
+ {
+ _paths.EnsureDirectories();
+ var leaseStream = new FileStream(
+ _paths.LeasePath(foreignPid, foreignStart),
+ FileMode.Create,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ FileOptions.DeleteOnClose);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var state = InteractiveDesktopState.CreateFresh();
+ state.TurnId = 1;
+ state.NextTicket = 2;
+ state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = "some-other-workflow" };
+ state.OwnerCommands.Add(new OwnerCommandEntry
+ {
+ Ticket = 1,
+ Pid = foreignPid,
+ ProcessStartTicksUtc = foreignStart,
+ Operation = "ui click",
+ Mode = UiTurnMode.DesktopExclusive,
+ Status = UiCommandStatus.Running,
+ });
+ _store.Publish(state);
+
+ return leaseStream;
+ }
+
+ [TestMethod]
+ public async Task ACommandQueuesWhileAnotherOwnerHoldsTheTurn()
+ {
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ using var cts = new CancellationTokenSource();
+ var ran = false;
+ var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) =>
+ {
+ ran = true;
+ return Task.FromResult(0);
+ }, cts.Token);
+
+ await Task.Delay(250);
+ Assert.IsFalse(ran, "the command must wait while another owner holds the turn");
+
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ Assert.AreEqual(1, _store.Read().State!.Waiters.Count,
+ "the command must be recorded as a global waiter");
+ }
+
+ await cts.CancelAsync();
+ await queued;
+ }
+
+ [TestMethod]
+ public async Task CancellingWhileQueuedExitsOneThirtyAndRemovesTheTicket()
+ {
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ using var cts = new CancellationTokenSource();
+ var ran = false;
+ var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) =>
+ {
+ ran = true;
+ return Task.FromResult(0);
+ }, cts.Token);
+
+ await Task.Delay(250);
+ await cts.CancelAsync();
+
+ Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued,
+ "a command cancelled while queued exits 130");
+ Assert.IsFalse(ran, "it never reached execution, so it has no UI side effects");
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var state = _store.Read().State!;
+ Assert.AreEqual(0, state.Waiters.Count, "cancellation must remove the waiter's ticket");
+ Assert.AreEqual("some-other-workflow", state.Owner!.Key,
+ "the cancelled command must not disturb the current owner");
+ }
+
+ [TestMethod]
+ public async Task CancellingWhileQueuedEmitsTheStructuredCancelledError()
+ {
+ using var foreignLease = OccupyTurnWithAnotherOwner();
+
+ var errorWriter = new StringWriter();
+ var command = new Command("probe");
+ command.Options.Add(WinAppRootCommand.JsonOption);
+ command.Options.Add(WinAppRootCommand.QuietOption);
+ command.Options.Add(WinAppRootCommand.VerboseOption);
+ var parseResult = command.Parse(["--json"]);
+ parseResult.InvocationConfiguration.Error = errorWriter;
+
+ using var cts = new CancellationTokenSource();
+ var queued = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", parseResult,
+ (_, _) => Task.FromResult(0), cts.Token);
+
+ await Task.Delay(250);
+ await cts.CancelAsync();
+ Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued);
+
+ var payload = errorWriter.ToString();
+ StringAssert.Contains(payload, "\"code\":\"cancelled\"");
+ StringAssert.Contains(payload, "\"waitedMs\"");
+ // Owner identity must never surface, in raw or hashed form.
+ Assert.IsFalse(payload.Contains("some-other-workflow", StringComparison.Ordinal));
+ Assert.IsFalse(payload.Contains("interactive-desktop-lock-tests", StringComparison.Ordinal));
+ }
+
+ [TestMethod]
+ public async Task AQueuedCommandProceedsOnceTheOtherOwnerIsGone()
+ {
+ var foreignLease = OccupyTurnWithAnotherOwner();
+
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) =>
+ {
+ started.SetResult();
+ return Task.FromResult(0);
+ }, CancellationToken.None);
+
+ await Task.Delay(200);
+ Assert.IsFalse(started.Task.IsCompleted);
+
+ // Closing the lease is what Windows does when that process exits or is killed. A crash does not
+ // renew the grace, so the turn is released immediately and this command is promoted.
+ foreignLease.Dispose();
+
+ Assert.AreEqual(0, await queued);
+ Assert.IsTrue(started.Task.IsCompleted);
+ }
+
+ private Task RunAsyncWithToken(
+ UiTurnMode mode, string operation, Func> body, CancellationToken token)
+ => _coordinator.RunCoordinatedAsync(mode, operation, Parse(), body, token);
+
+ // ------------------------------------------ cancellation must not swallow coordination faults
+
+ [TestMethod]
+ public async Task AnActiveRecordingThatFinalizesOnCancellationStillRenewsTheGrace()
+ {
+ // `ui record` observes Ctrl+C deliberately: it stops capturing, finalizes the MP4, and returns
+ // success. That is a completed command, so the owner keeps its turn and its grace — the agent is
+ // expected to issue a follow-up command next. "Completed normally" therefore means the body
+ // returned, not that the token is unset.
+ Assert.AreEqual(0, await RunAsync(UiTurnMode.TurnShared, "ui record", (_, _) => Task.FromResult(0)));
+ var deadlineBeforeRecording = ReadOwnerDeadline();
+
+ await Task.Delay(50);
+
+ using var cts = new CancellationTokenSource();
+ var bodyObservedCancellation = false;
+ var exitCode = await RunAsyncWithToken(UiTurnMode.TurnShared, "ui record", async (_, token) =>
+ {
+ // Stands in for a capture loop that is interrupted and finalizes its output.
+ await cts.CancelAsync();
+ bodyObservedCancellation = token.IsCancellationRequested;
+ return 0;
+ }, cts.Token);
+
+ Assert.AreEqual(0, exitCode, "a finalized recording reports success");
+ Assert.IsTrue(bodyObservedCancellation,
+ "the recording must actually observe cancellation, or this test proves nothing");
+ Assert.IsTrue(ReadOwnerDeadline() > deadlineBeforeRecording,
+ "a recording that finalized and returned successfully must renew its owner's idle grace");
+ }
+
+ private long ReadOwnerDeadline()
+ {
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ return _store.Read().State!.IdleExpiresTick64;
+ }
+
+ private static ParseResult ParseWithWriter(TextWriter errorWriter)
+ {
+ var command = new Command("probe");
+ command.Options.Add(WinAppRootCommand.JsonOption);
+ command.Options.Add(WinAppRootCommand.QuietOption);
+ command.Options.Add(WinAppRootCommand.VerboseOption);
+ var parseResult = command.Parse(["--json"]);
+ parseResult.InvocationConfiguration.Error = errorWriter;
+ return parseResult;
+ }
+
+ // ------------------------------------------------- ownership lapsing mid-registration (observe)
+
+ ///
+ /// The turn is lost between the owner check and the admission that follows it.
+ ///
+ ///
+ /// normalizes again, so an owner that was
+ /// current a moment earlier can be gone by the time admission runs — here because the shell the
+ /// reservation was derived from exits in between. The observation must then run fully detached:
+ /// keeping the lease open would publish liveness for a participant with no state entry, and
+ /// completing later would adjust a different owner's turn.
+ ///
+}
\ No newline at end of file
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.Recovery.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.Recovery.cs
new file mode 100644
index 000000000..870700a20
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.Recovery.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+namespace WinApp.Cli.Tests;
+
+using WinApp.Cli.Services.InteractiveDesktop;
+
+///
+/// Recovery across real processes, where the wake-up nobody sends is the interesting case.
+///
+///
+///
+/// Waiting is push-based, so an ordinary handoff is somebody publishing and then signalling. None of
+/// that happens when a process is killed: there is no completion to publish and nobody left to send a
+/// signal. These tests kill real winapp.exe processes inside the queue and require the
+/// survivors to get the desktop anyway, which is what the recovery deadlines exist for.
+///
+///
+/// The turn holder is this process rather than a child, because a child that can be killed also has
+/// to be a child that stays in coordination, and a command that reaches its turn without a live target
+/// window finishes immediately. The killed-owner case is covered where it can be made
+/// deterministic instead: InteractiveDesktopLockTests closes an owner's lease, which is exactly
+/// what Windows does when that process dies.
+///
+///
+/// Timings are generous upper bounds. The claim under test is that recovery happens without anyone
+/// being told, not that it happens on a particular schedule.
+///
+///
+public partial class InteractiveDesktopMultiprocessTests
+{
+ [TestMethod]
+ public async Task KillingTheQueueHeadLetsTheNextWaiterThrough()
+ {
+ // The head is what everyone behind it is effectively waiting on, and the one position where a
+ // corpse blocks the whole queue until somebody prunes it.
+ var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var holder = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ async (_, _) =>
+ {
+ holderStarted.SetResult();
+ await releaseHolder.Task;
+ return 0;
+ },
+ CancellationToken.None);
+
+ await holderStarted.Task;
+
+ var head = StartQueuedClick("multiprocess-killed-head-first");
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Waiters.Any(w => w.Pid == head.Id)),
+ "the first child must be queued before the second arrives");
+
+ var behind = StartQueuedClick("multiprocess-killed-head-second");
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Waiters.Count == 2),
+ "both children must be queued before the head is killed");
+
+ head.Kill(entireProcessTree: true);
+ releaseHolder.SetResult();
+ await holder;
+
+ Assert.IsTrue(
+ behind.WaitForExit(45_000),
+ "the surviving waiter must prune the dead head and take the turn without being told");
+
+ // The app does not exist, so the command fails after acquiring its turn — reaching that failure
+ // is the proof that it got the desktop.
+ Assert.AreEqual(1, behind.ExitCode);
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Waiters.Count == 0 && s.OwnerCommands.Count == 0),
+ "neither the corpse nor the survivor may be left in the queue");
+ }
+
+ [TestMethod]
+ public async Task ADeadWaiterDoesNotHoldQueueCapacityAgainstNewCommands()
+ {
+ // What the cap counts, demonstrated rather than read off the constant: live foreign waiters. A
+ // process that started and died is not one, however recently its entry was written.
+ var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var holder = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ async (_, _) =>
+ {
+ holderStarted.SetResult();
+ await releaseHolder.Task;
+ return 0;
+ },
+ CancellationToken.None);
+
+ await holderStarted.Task;
+
+ var doomed = StartQueuedClick("multiprocess-cap-doomed");
+ Assert.IsTrue(await WaitForStateAsync(s => s.Waiters.Any(w => w.Pid == doomed.Id)));
+
+ doomed.Kill(entireProcessTree: true);
+
+ var arriving = StartQueuedClick("multiprocess-cap-arriving");
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Waiters.Any(w => w.Pid == arriving.Id)),
+ "a new command must still be admitted while a dead entry is nominally in the queue");
+
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Waiters.All(w => w.Pid != doomed.Id)),
+ "the dead waiter's entry must be pruned rather than left occupying a slot");
+
+ releaseHolder.SetResult();
+ await holder;
+
+ Assert.IsTrue(arriving.WaitForExit(45_000), "the live waiter must still get its turn");
+ Assert.AreEqual(1, arriving.ExitCode);
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs
new file mode 100644
index 000000000..588b7f052
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs
@@ -0,0 +1,476 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using System.Text.Json;
+using Microsoft.Extensions.Logging.Abstractions;
+using Spectre.Console.Testing;
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// True multiprocess coverage of cooperative desktop turns (issue #764): a real second
+/// winapp.exe queues behind a turn this test process holds, and the file protocol — leases,
+/// state.lock, active.lock — is exercised across an OS process boundary.
+///
+///
+///
+/// These cannot be simulated in one process: participant identity is (pid, processStartTicks),
+/// so two "owners" inside a single process would share one identity and one lease file. Only separate
+/// processes exercise the real protocol.
+///
+///
+/// Gated on WINAPP_UI_MULTIPROCESS_TESTS=1 and a published winapp.exe, so the canonical
+/// build does not depend on build artifacts being present.
+///
+///
+[TestClass]
+[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY is process-wide and the child inherits it.
+[TestCategory("Interactive")]
+[TestCategory("UiCoordination")]
+public partial class InteractiveDesktopMultiprocessTests
+{
+ private const string GateVariable = "WINAPP_UI_MULTIPROCESS_TESTS";
+
+ private string _lockDirectory = null!;
+ private string? _previousLockOverride;
+ private string? _previousOwnerId;
+ private string _winappPath = null!;
+ private InteractiveDesktopPaths _paths = null!;
+ private ParticipantRegistry _participants = null!;
+ private InteractiveDesktopStateStore _store = null!;
+ private InteractiveDesktopLock _coordinator = null!;
+ private readonly List _children = [];
+
+ [TestInitialize]
+ public void Setup()
+ {
+ if (!string.Equals(Environment.GetEnvironmentVariable(GateVariable), "1", StringComparison.Ordinal))
+ {
+ Assert.Inconclusive(
+ $"Set {GateVariable}=1 (and build the CLI) to run multiprocess UI coordination coverage.");
+ }
+
+ _winappPath = WinappTestBinary.Resolve();
+
+ _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-mp-{Guid.NewGuid():N}");
+ _previousLockOverride = Environment.GetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable);
+ _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable);
+
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory);
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, "multiprocess-test-holder");
+
+ var inspector = new ProcessInspector();
+ _paths = new InteractiveDesktopPaths(inspector);
+ _participants = new ParticipantRegistry(_paths, inspector, NullLogger.Instance);
+ _store = new InteractiveDesktopStateStore(
+ _paths, _participants, new TickCountClock(), NullLogger.Instance);
+ _coordinator = new InteractiveDesktopLock(
+ _store, _paths, _participants, new UiOwnerResolver(), inspector,
+ new TickCountClock(), new FakePollDelay(),
+ // Real named events: the whole point of these tests is that a wake-up crosses a process
+ // boundary to a genuine winapp.exe child.
+ new ParticipantSignals(inspector, NullLogger.Instance),
+ new TestConsole(),
+ NullLogger.Instance);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ foreach (var child in _children)
+ {
+ try
+ {
+ if (!child.HasExited)
+ {
+ child.Kill(entireProcessTree: true);
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // Already gone.
+ }
+
+ child.Dispose();
+ }
+
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride);
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, _previousOwnerId);
+
+ try
+ {
+ if (_lockDirectory is not null && Directory.Exists(_lockDirectory))
+ {
+ Directory.Delete(_lockDirectory, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // A leaked temp directory must never fail a test.
+ }
+ }
+
+ ///
+ /// Starts a real winapp ui click against a process that does not exist. Preflight passes
+ /// (an app and a selector were supplied), so the command genuinely enters coordination, waits its
+ /// turn, and only then fails to resolve the app — which is exactly the coordination behavior under
+ /// test, without needing a live target window.
+ ///
+ private Process StartQueuedClick(string ownerId)
+ {
+ var startInfo = new ProcessStartInfo(_winappPath)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ ArgumentList = { "ui", "click", "some-selector", "-a", "winapp-no-such-app-zzz", "--json" },
+ };
+ startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory;
+ startInfo.Environment[UiOwnerResolver.WorkflowIdVariable] = ownerId;
+ startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0";
+
+ var child = Process.Start(startInfo)!;
+ _children.Add(child);
+ return child;
+ }
+
+ private InteractiveDesktopState ReadState()
+ {
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ return _store.Read().State!;
+ }
+
+ /// Waits until holds over the shared state, or times out.
+ private async Task WaitForStateAsync(Func predicate, int timeoutMs = 15_000)
+ {
+ var deadline = Stopwatch.StartNew();
+ while (deadline.ElapsedMilliseconds < timeoutMs)
+ {
+ if (predicate(ReadState()))
+ {
+ return true;
+ }
+
+ await Task.Delay(50);
+ }
+
+ return false;
+ }
+
+ [TestMethod]
+ public async Task ASecondProcessQueuesBehindTheTurnAndProceedsWhenItIsReleased()
+ {
+ var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var holder = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ async (_, _) =>
+ {
+ holderStarted.SetResult();
+ await releaseHolder.Task;
+ return 0;
+ },
+ CancellationToken.None);
+
+ await holderStarted.Task;
+
+ var child = StartQueuedClick("multiprocess-test-other");
+
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Waiters.Count == 1),
+ "the second process must register as a global waiter while another owner holds the turn");
+
+ var waiterPid = ReadState().Waiters[0].Pid;
+ Assert.AreEqual(child.Id, waiterPid, "the waiter must be the real child process");
+ Assert.IsFalse(child.HasExited, "the child must still be waiting, not running");
+
+ releaseHolder.SetResult();
+ await holder;
+
+ Assert.IsTrue(child.WaitForExit(30_000), "the child must proceed once the turn is released");
+
+ // The app does not exist, so the command fails after acquiring its turn — the point is that it
+ // got that far only after the holder finished.
+ Assert.AreEqual(1, child.ExitCode);
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Waiters.Count == 0 && s.OwnerCommands.Count == 0),
+ "the child must remove its own entry on completion");
+ }
+
+ [TestMethod]
+ public async Task KillingAQueuedProcessReleasesItsLeaseAndReclaimsItsTicket()
+ {
+ var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var holder = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ async (_, _) =>
+ {
+ holderStarted.SetResult();
+ await releaseHolder.Task;
+ return 0;
+ },
+ CancellationToken.None);
+
+ await holderStarted.Task;
+
+ var child = StartQueuedClick("multiprocess-test-other");
+ Assert.IsTrue(await WaitForStateAsync(s => s.Waiters.Count == 1));
+
+ var leasePath = _paths.LeasePath(child.Id, child.StartTime.ToUniversalTime().Ticks);
+ Assert.IsTrue(File.Exists(leasePath), "a queued participant must hold a lease file");
+
+ // Forced termination is the documented recovery for a stuck process. Windows closes the handle,
+ // which deletes the DeleteOnClose lease — that is what makes heartbeats unnecessary.
+ child.Kill(entireProcessTree: true);
+ Assert.IsTrue(child.WaitForExit(30_000));
+
+ Assert.IsTrue(
+ await WaitForStateAsync(_ => !File.Exists(leasePath)),
+ "Windows must delete the participant lease when the holder is killed");
+
+ releaseHolder.SetResult();
+ await holder;
+
+ // The next coordination pass prunes the dead waiter rather than queueing behind it forever.
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Waiters.Count == 0),
+ "a killed waiter's ticket must be reclaimed by the next coordinator");
+ }
+
+ [TestMethod]
+ public async Task TwoProcessesNeverHoldTheDesktopSectionAtTheSameTime()
+ {
+ // Hold active.lock from this process for a beat, and prove the child cannot take it meanwhile.
+ var sectionEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseSection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var holder = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ async (turn, ct) =>
+ {
+ await using (await turn.EnterAsync(ct))
+ {
+ sectionEntered.SetResult();
+ await releaseSection.Task;
+ }
+
+ return 0;
+ },
+ CancellationToken.None);
+
+ await sectionEntered.Task;
+ Assert.IsFalse(_store.IsActiveLockFree(), "this process holds the desktop section");
+
+ var child = StartQueuedClick("multiprocess-test-other");
+ Assert.IsTrue(await WaitForStateAsync(s => s.Waiters.Count == 1));
+ Assert.IsFalse(child.HasExited);
+ Assert.IsFalse(_store.IsActiveLockFree(), "the child must not have taken active.lock");
+
+ releaseSection.SetResult();
+ await holder;
+ Assert.IsTrue(child.WaitForExit(30_000));
+ }
+
+ [TestMethod]
+ public async Task ASameOwnerProcessJoinsTheTurnInsteadOfQueueingGlobally()
+ {
+ var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var holder = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ async (_, _) =>
+ {
+ holderStarted.SetResult();
+ await releaseHolder.Task;
+ return 0;
+ },
+ CancellationToken.None);
+
+ await holderStarted.Task;
+
+ // Same explicit workflow id as this test process, so the child is the *same* logical workflow.
+ var child = StartQueuedClick("multiprocess-test-holder");
+
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.OwnerCommands.Count == 2),
+ "a same-owner command joins the owner's command list rather than the global queue");
+ Assert.AreEqual(0, ReadState().Waiters.Count);
+
+ releaseHolder.SetResult();
+ await holder;
+ Assert.IsTrue(child.WaitForExit(30_000));
+ }
+
+ [TestMethod]
+ public async Task AnObservationFromAnotherOwnerRunsConcurrentlyWithATurn()
+ {
+ var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var holder = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ async (_, _) =>
+ {
+ holderStarted.SetResult();
+ await releaseHolder.Task;
+ return 0;
+ },
+ CancellationToken.None);
+
+ await holderStarted.Task;
+
+ // list-windows is an Observe command: it must not wait for anyone's turn.
+ var startInfo = new ProcessStartInfo(_winappPath)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ ArgumentList = { "ui", "list-windows", "--json" },
+ };
+ startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory;
+ startInfo.Environment[UiOwnerResolver.WorkflowIdVariable] = "multiprocess-test-observer";
+ startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0";
+
+ using var observer = Process.Start(startInfo)!;
+
+ // list-windows emits a large JSON payload. Drain both pipes concurrently — a child that fills
+ // the pipe buffer while nobody reads it blocks on write, which would look like a coordination
+ // hang rather than the test harness deadlock it actually is.
+ var stdout = observer.StandardOutput.ReadToEndAsync();
+ var stderr = observer.StandardError.ReadToEndAsync();
+
+ Assert.IsTrue(observer.WaitForExit(30_000),
+ "a non-owner observation must run immediately, not wait for the desktop");
+ await Task.WhenAll(stdout, stderr);
+
+ Assert.AreEqual(0, observer.ExitCode);
+ Assert.AreEqual(0, ReadState().Waiters.Count, "an observation must never enter the queue");
+
+ releaseHolder.SetResult();
+ await holder;
+ }
+
+ [TestMethod]
+ public async Task CorruptStateIsNotResetWhileAnotherProcessIsLive()
+ {
+ var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var holder = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ async (_, _) =>
+ {
+ holderStarted.SetResult();
+ await releaseHolder.Task;
+ return 0;
+ },
+ CancellationToken.None);
+
+ await holderStarted.Task;
+
+ // Corrupt the shared document while this process is provably mid-workflow.
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ File.WriteAllText(_paths.StatePath, "{ not json at all");
+ }
+
+ var child = StartQueuedClick("multiprocess-test-other");
+ Assert.IsTrue(child.WaitForExit(30_000));
+
+ var stderr = child.StandardError.ReadToEnd() + child.StandardOutput.ReadToEnd();
+ Assert.AreEqual(1, child.ExitCode);
+ StringAssert.Contains(stderr, UiCoordinationErrorCodes.Unavailable,
+ "a mutating command must fail closed rather than rebuild state over a live participant");
+
+ releaseHolder.SetResult();
+ await holder;
+ }
+
+ [TestMethod]
+ public async Task AnInvalidWorkflowIdIsRejectedByTheRealBinaryBeforeAnyWork()
+ {
+ var startInfo = new ProcessStartInfo(_winappPath)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ ArgumentList = { "ui", "click", "some-selector", "-a", "winapp-no-such-app-zzz", "--json" },
+ };
+ startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory;
+ startInfo.Environment[UiOwnerResolver.WorkflowIdVariable] = " ";
+ startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0";
+
+ using var child = Process.Start(startInfo)!;
+ Assert.IsTrue(child.WaitForExit(30_000));
+
+ var output = child.StandardError.ReadToEnd() + child.StandardOutput.ReadToEnd();
+ Assert.AreEqual(1, child.ExitCode);
+ StringAssert.Contains(output, UiCoordinationErrorCodes.InvalidWorkflowId);
+ Assert.IsFalse(_participants.AnyLiveParticipant(), "a rejected command must leave no lease behind");
+
+ await Task.CompletedTask;
+ }
+
+ [TestMethod]
+ public async Task StateStaysReadableWhileAnotherProcessHoldsTheDesktopSection()
+ {
+ var sectionEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseSection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var holder = _coordinator.RunCoordinatedAsync(
+ UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(),
+ async (turn, ct) =>
+ {
+ await using (await turn.EnterAsync(ct))
+ {
+ sectionEntered.SetResult();
+ await releaseSection.Task;
+ }
+
+ return 0;
+ },
+ CancellationToken.None);
+
+ await sectionEntered.Task;
+
+ // A queued command must still be able to read and update metadata while the desktop is busy,
+ // otherwise nothing could ever join the queue.
+ var child = StartQueuedClick("multiprocess-test-other");
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Waiters.Count == 1),
+ "state.lock must be independent of active.lock");
+
+ var raw = File.ReadAllText(_paths.StatePath);
+ using var document = JsonDocument.Parse(raw);
+ Assert.AreEqual(1, document.RootElement.GetProperty("version").GetInt32());
+
+ releaseSection.SetResult();
+ await holder;
+ Assert.IsTrue(child.WaitForExit(30_000));
+ }
+}
+
+/// Minimal parse results for coordination tests that do not exercise a real command.
+internal static class UiCoordinationTestParse
+{
+ public static System.CommandLine.ParseResult Quiet()
+ {
+ var command = new System.CommandLine.Command("probe");
+ command.Options.Add(WinApp.Cli.Commands.WinAppRootCommand.JsonOption);
+ command.Options.Add(WinApp.Cli.Commands.WinAppRootCommand.QuietOption);
+ command.Options.Add(WinApp.Cli.Commands.WinAppRootCommand.VerboseOption);
+ return command.Parse(["--quiet"]);
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs
new file mode 100644
index 000000000..a0d50dfdc
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs
@@ -0,0 +1,332 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Security.AccessControl;
+using System.Security.Principal;
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// What happens to coordination artifacts that were already sitting in a directory when its
+/// permissions had to be repaired.
+///
+///
+/// Repairing the directory does not repair its contents. Windows keeps an explicit ACE on a child
+/// file when a protected DACL is written to its parent — inheritance changes propagate only inherited
+/// ACEs — so a file placed there beforehand stays writable by whoever placed it. Coordination would
+/// then go on reading and trusting state another user can still rewrite, which is exactly the
+/// isolation this hardening exists to provide.
+///
+[TestClass]
+[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY is process-wide.
+public class InteractiveDesktopPathsHardeningTests
+{
+ private string _root = null!;
+ private string? _previousOverride;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _root = Path.Combine(Path.GetTempPath(), $"winapp-acl-{Guid.NewGuid():N}");
+ _previousOverride = Environment.GetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable);
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _root);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousOverride);
+
+ try
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // A leaked temp directory must never fail a test.
+ }
+ }
+
+ /// Writes a file and grants Everyone full control on it, as a pre-seeder would.
+ private static string SeedWorldWritable(string directory, string fileName)
+ {
+ Directory.CreateDirectory(directory);
+ var path = Path.Combine(directory, fileName);
+ File.WriteAllText(path, "{\"version\":1,\"owner\":\"attacker\"}");
+
+ var file = new FileInfo(path);
+ var acl = file.GetAccessControl();
+ acl.AddAccessRule(new FileSystemAccessRule(
+ new SecurityIdentifier(WellKnownSidType.WorldSid, null),
+ FileSystemRights.FullControl,
+ AccessControlType.Allow));
+ file.SetAccessControl(acl);
+
+ Assert.IsTrue(HasForeignGrant(path), "arrange failed: the Everyone ACE was not applied");
+ return path;
+ }
+
+ private static bool HasForeignGrant(string path)
+ {
+ var currentUser = WindowsIdentity.GetCurrent().User!;
+ var rules = new FileInfo(path).GetAccessControl()
+ .GetAccessRules(includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier));
+ return rules.Cast()
+ .Any(r => r.IdentityReference is SecurityIdentifier sid && sid != currentUser);
+ }
+
+ [TestMethod]
+ public void APreSeededStateFileWithAnEveryoneGrantIsDiscarded()
+ {
+ // The reported repro. The directory's DACL is inherited, so it will be repaired — and the
+ // explicit Everyone ACE on this file would survive that repair untouched.
+ var seeded = SeedWorldWritable(_root, "interactive-desktop-1.state.json");
+
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsFalse(File.Exists(seeded),
+ "a world-writable state file that predates the repair cannot be trusted or kept");
+ }
+
+ [TestMethod]
+ public void APreSeededActiveLockWithAnEveryoneGrantIsDiscarded()
+ {
+ // The other half of the repro: holding active.lock would let another user block this desktop.
+ var seeded = SeedWorldWritable(_root, "interactive-desktop-1.active.lock");
+
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsFalse(File.Exists(seeded));
+ }
+
+ [TestMethod]
+ public void APreSeededLeaseIsDiscarded()
+ {
+ // A lease is liveness evidence. One written by somebody else is a claim about a participant
+ // that never existed, and it would keep a phantom queue entry unprunable.
+ var participants = Path.Combine(_root, "participants");
+ var seeded = SeedWorldWritable(participants, "interactive-desktop-1-4242-638000000000000000.lease");
+
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsFalse(File.Exists(seeded));
+ }
+
+ [TestMethod]
+ public void AQuarantinedCorruptStateCopyIsAlsoDiscarded()
+ {
+ var seeded = SeedWorldWritable(_root, "state.corrupt-20260101T000000.000Z.json");
+
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsFalse(File.Exists(seeded));
+ }
+
+ [TestMethod]
+ public void AnArtifactThatSurvivesTheRepairFailsClosed()
+ {
+ // Held open, so it cannot be deleted. A process keeping a handle in a namespace that was just
+ // proven untrusted is not liveness evidence worth acting on, so this must refuse to run rather
+ // than coordinate through storage a third party can still write.
+ Directory.CreateDirectory(_root);
+ var held = Path.Combine(_root, "interactive-desktop-1.state.json");
+ using var handle = new FileStream(held, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
+
+ var paths = new InteractiveDesktopPaths(new ProcessInspector());
+ var ex = Assert.ThrowsExactly(() => paths.EnsureDirectories());
+
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+ StringAssert.Contains(ex.Message, "could not be removed");
+ Assert.IsNotNull(ex.RecoveryHint, "a fail-closed error has to say what to do next");
+ }
+
+ [TestMethod]
+ public void UnrelatedFilesInAnOverrideDirectoryAreLeftAlone()
+ {
+ // WINAPP_UI_LOCK_DIRECTORY can point at a path holding somebody's own files. Those are never
+ // read by coordination, so they are not a trust question — and deleting them would be
+ // destroying data that was not ours to touch.
+ Directory.CreateDirectory(_root);
+ var unrelated = Path.Combine(_root, "my-notes.txt");
+ File.WriteAllText(unrelated, "keep me");
+
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsTrue(File.Exists(unrelated), "only this feature's own artifacts may be discarded");
+ Assert.AreEqual("keep me", File.ReadAllText(unrelated));
+ }
+
+ [TestMethod]
+ public void AnInsecureStateFileUnderAnAlreadySecuredDirectoryIsAlsoDiscarded()
+ {
+ // The case that is NOT covered by the directory-repair path. Here the directory is already
+ // current-user-only, so the repair fast path returns early — but a state file inside it still
+ // carries a foreign grant, exactly as it would if an earlier build had secured the directory
+ // without clearing its contents.
+ var paths = new InteractiveDesktopPaths(new ProcessInspector());
+ paths.EnsureDirectories();
+ Assert.IsTrue(
+ InteractiveDesktopPaths.IsCurrentUserOnly(
+ new DirectoryInfo(paths.LockDirectory).GetAccessControl(), WindowsIdentity.GetCurrent().User!),
+ "arrange failed: the directory should already be secured");
+
+ File.WriteAllText(paths.StatePath, "{\"version\":1,\"owner\":\"attacker\"}");
+ var file = new FileInfo(paths.StatePath);
+ var acl = file.GetAccessControl();
+ acl.AddAccessRule(new FileSystemAccessRule(
+ new SecurityIdentifier(WellKnownSidType.WorldSid, null),
+ FileSystemRights.FullControl,
+ AccessControlType.Allow));
+ file.SetAccessControl(acl);
+
+ // A fresh instance repeats the whole check; the directory needs no repair this time.
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsFalse(File.Exists(paths.StatePath),
+ "a world-writable state file must be discarded even when its directory is already secure");
+ }
+
+ [TestMethod]
+ public void AnInsecureActiveLockUnderAnAlreadySecuredDirectoryIsAlsoDiscarded()
+ {
+ var paths = new InteractiveDesktopPaths(new ProcessInspector());
+ paths.EnsureDirectories();
+
+ File.WriteAllText(paths.ActiveLockPath, "x");
+ var file = new FileInfo(paths.ActiveLockPath);
+ var acl = file.GetAccessControl();
+ acl.AddAccessRule(new FileSystemAccessRule(
+ new SecurityIdentifier(WellKnownSidType.WorldSid, null),
+ FileSystemRights.FullControl,
+ AccessControlType.Allow));
+ file.SetAccessControl(acl);
+
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsFalse(File.Exists(paths.ActiveLockPath),
+ "another user able to hold active.lock would be able to block this desktop");
+ }
+
+ [TestMethod]
+ public void AnInsecureStateFileThatCannotBeReplacedFailsClosed()
+ {
+ var paths = new InteractiveDesktopPaths(new ProcessInspector());
+ paths.EnsureDirectories();
+
+ File.WriteAllText(paths.StatePath, "{}");
+ var file = new FileInfo(paths.StatePath);
+ var acl = file.GetAccessControl();
+ acl.AddAccessRule(new FileSystemAccessRule(
+ new SecurityIdentifier(WellKnownSidType.WorldSid, null),
+ FileSystemRights.FullControl,
+ AccessControlType.Allow));
+ file.SetAccessControl(acl);
+
+ using var held = new FileStream(paths.StatePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+
+ var ex = Assert.ThrowsExactly(
+ () => new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories());
+
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+ StringAssert.Contains(ex.Message, "reachable by another user");
+ }
+
+ [TestMethod]
+ public void AStateFileWithAdministratorsAndSystemAcesIsKept()
+ {
+ // Half of the CI regression this check caused, in the half reproducible without elevation.
+ // Administrators and SYSTEM can already take ownership of anything, so their presence is not a
+ // boundary this can defend and must not read as hostile. (The other half is a file *owned* by
+ // BUILTIN\Administrators, which Windows assigns by default under an elevated process; setting
+ // that owner needs SeRestorePrivilege, so it is covered by the same carve-out and exercised by
+ // the elevated CI lane rather than arranged here.)
+ var paths = new InteractiveDesktopPaths(new ProcessInspector());
+ paths.EnsureDirectories();
+
+ File.WriteAllText(paths.StatePath, "{\"version\":1}");
+ var file = new FileInfo(paths.StatePath);
+ var acl = file.GetAccessControl();
+ acl.AddAccessRule(new FileSystemAccessRule(
+ new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
+ FileSystemRights.FullControl,
+ AccessControlType.Allow));
+ acl.AddAccessRule(new FileSystemAccessRule(
+ new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
+ FileSystemRights.FullControl,
+ AccessControlType.Allow));
+ file.SetAccessControl(acl);
+
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsTrue(File.Exists(paths.StatePath),
+ "an Administrators/SYSTEM ACE is not another standard user and must not discard live state");
+ }
+
+ [TestMethod]
+ public void AStateFileGrantedToAnotherStandardUserIsStillDiscarded()
+ {
+ // The boundary that does matter, and the one the Administrators carve-out must not widen:
+ // an ACE naming an ordinary account that is not this user.
+ var paths = new InteractiveDesktopPaths(new ProcessInspector());
+ paths.EnsureDirectories();
+
+ File.WriteAllText(paths.StatePath, "{\"version\":1}");
+ var file = new FileInfo(paths.StatePath);
+ var acl = file.GetAccessControl();
+ // Guests is a well-known group, but not a privileged one — it stands in for any other account.
+ acl.AddAccessRule(new FileSystemAccessRule(
+ new SecurityIdentifier(WellKnownSidType.BuiltinGuestsSid, null),
+ FileSystemRights.FullControl,
+ AccessControlType.Allow));
+ file.SetAccessControl(acl);
+
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsFalse(File.Exists(paths.StatePath),
+ "a grant to any non-privileged identity other than this user is still untrusted");
+ }
+
+ [TestMethod]
+ public void AnAlreadySecuredDirectoryKeepsItsContents()
+ {
+ // The steady state, and the case that must not become destructive: a second winapp process
+ // joining a directory this user already secured must not discard the first process's state.
+ // This is also the every-command path, so it must stay free of per-file work beyond the three
+ // trusted files.
+ var paths = new InteractiveDesktopPaths(new ProcessInspector());
+ paths.EnsureDirectories();
+
+ File.WriteAllText(paths.StatePath, "{\"version\":1}");
+
+ new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories();
+
+ Assert.IsTrue(File.Exists(paths.StatePath),
+ "a state file this user owns, inheriting from a secured directory, is trustworthy");
+ Assert.AreEqual("{\"version\":1}", File.ReadAllText(paths.StatePath));
+ }
+
+ [TestMethod]
+ public void RepairLeavesBothDirectoriesReachableOnlyByThisUser()
+ {
+ Directory.CreateDirectory(_root);
+
+ var paths = new InteractiveDesktopPaths(new ProcessInspector());
+ paths.EnsureDirectories();
+
+ var currentUser = WindowsIdentity.GetCurrent().User!;
+ Assert.IsTrue(
+ InteractiveDesktopPaths.IsCurrentUserOnly(
+ new DirectoryInfo(paths.LockDirectory).GetAccessControl(), currentUser));
+ Assert.IsTrue(
+ InteractiveDesktopPaths.IsCurrentUserOnly(
+ new DirectoryInfo(paths.ParticipantsDirectory).GetAccessControl(), currentUser),
+ "the participants directory holds leases and needs the same protection");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs
new file mode 100644
index 000000000..3049862fe
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs
@@ -0,0 +1,483 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using Microsoft.Extensions.Logging.Abstractions;
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Real-app acceptance coverage for cooperative desktop turns (issue #764 §18.3).
+///
+///
+///
+/// Unlike the scheduler and store suites, nothing here is faked. A real window (,
+/// hosted on this test process's UI thread, with a genuine MenuStrip whose drop-down Windows
+/// dismisses on foreground loss) plays the target app, and every agent is a separate real
+/// winapp.exe process carrying its own WINAPP_UI_WORKFLOW_ID. The properties under test —
+/// a transient menu surviving another agent's attempt to act, a reasoning gap handing the turn away,
+/// and a recording pinning its owner — are only meaningful end to end, so they are asserted against
+/// observable desktop state rather than scheduler internals.
+///
+///
+/// Gated on WINAPP_UI_MULTIPROCESS_TESTS=1 plus a published winapp.exe: these need an
+/// interactive desktop and build artifacts, so the canonical build skips them.
+///
+///
+[TestClass]
+[DoNotParallelize] // Drives the real foreground window and a process-wide lock-directory override.
+[TestCategory("Interactive")]
+[TestCategory("UiCoordination")]
+public class InteractiveDesktopRealAppTests : IDisposable
+{
+ private const string GateVariable = "WINAPP_UI_MULTIPROCESS_TESTS";
+ private const string OwnerA = "realapp-agent-a";
+ private const string OwnerB = "realapp-agent-b";
+
+ private string _lockDirectory = null!;
+ private string? _previousLockOverride;
+ private string? _previousOwnerId;
+ private string _winappPath = null!;
+ private string _scratchDirectory = null!;
+ private UiaTestFixture _fixture = null!;
+ private InteractiveDesktopStateStore _store = null!;
+ private Stopwatch? _graceWatch;
+ private readonly List _children = [];
+
+ [TestInitialize]
+ public void Setup()
+ {
+ if (!string.Equals(Environment.GetEnvironmentVariable(GateVariable), "1", StringComparison.Ordinal))
+ {
+ Assert.Inconclusive(
+ $"Set {GateVariable}=1 on an interactive desktop (and build the CLI) to run real-app UI coordination coverage.");
+ }
+
+ _winappPath = WinappTestBinary.Resolve();
+
+ _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-realapp-{Guid.NewGuid():N}");
+ _scratchDirectory = Path.Combine(Path.GetTempPath(), $"winapp-realapp-out-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_scratchDirectory);
+
+ _previousLockOverride = Environment.GetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable);
+ _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable);
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory);
+
+ var inspector = new ProcessInspector();
+ var paths = new InteractiveDesktopPaths(inspector);
+ var participants = new ParticipantRegistry(paths, inspector, NullLogger.Instance);
+ _store = new InteractiveDesktopStateStore(
+ paths, participants, new TickCountClock(), NullLogger.Instance);
+
+ _fixture = new UiaTestFixture();
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ foreach (var child in _children)
+ {
+ try
+ {
+ if (!child.HasExited)
+ {
+ child.Kill(entireProcessTree: true);
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // Already gone.
+ }
+
+ child.Dispose();
+ }
+
+ // An open drop-down holds a desktop-wide keyboard capture. Leaving one behind would silently
+ // swallow input in the next test, so menu mode is exited before the window goes away.
+ try
+ {
+ _fixture?.CloseFileMenu();
+ }
+ catch (InvalidOperationException)
+ {
+ // The UI thread is already gone; nothing to release.
+ }
+
+ _fixture?.Dispose();
+ _fixture = null!;
+
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride);
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, _previousOwnerId);
+
+ foreach (var directory in new[] { _lockDirectory, _scratchDirectory })
+ {
+ try
+ {
+ if (directory is not null && Directory.Exists(directory))
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // A leaked temp directory must never fail a test.
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------ real winapp.exe agents
+
+ private sealed record AgentRun(Process Process, Task Completion, Task Output);
+
+ ///
+ /// Launches a real winapp.exe as against the fixture window.
+ ///
+ ///
+ /// Both pipes are drained concurrently: ui commands emit payloads large enough to fill the
+ /// pipe buffer, and waiting for exit without reading would deadlock.
+ ///
+ private AgentRun StartAgent(string ownerId, params string[] args)
+ {
+ var startInfo = new ProcessStartInfo(_winappPath)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+
+ foreach (var arg in args)
+ {
+ startInfo.ArgumentList.Add(arg);
+ }
+
+ startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory;
+ startInfo.Environment[UiOwnerResolver.WorkflowIdVariable] = ownerId;
+ startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0";
+
+ var process = Process.Start(startInfo)!;
+ _children.Add(process);
+
+ var stdout = process.StandardOutput.ReadToEndAsync();
+ var stderr = process.StandardError.ReadToEndAsync();
+ var completion = Task.Run(async () =>
+ {
+ await process.WaitForExitAsync();
+ return process.ExitCode;
+ });
+ var output = Task.Run(async () => await stdout + await stderr);
+
+ return new AgentRun(process, completion, output);
+ }
+
+ private async Task<(int ExitCode, string Output)> RunAgentAsync(string ownerId, params string[] args)
+ {
+ var run = StartAgent(ownerId, args);
+ return (await run.Completion, await run.Output);
+ }
+
+ /// Arguments that target the fixture window precisely (HWND beats process-name matching).
+ private string[] TargetArgs => ["-w", _fixture.Hwnd.ToString(System.Globalization.CultureInfo.InvariantCulture)];
+
+ private string[] WithTarget(params string[] args) => [.. args, .. TargetArgs, "--json"];
+
+ private InteractiveDesktopState ReadState()
+ {
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ return _store.Read().State!;
+ }
+
+ ///
+ /// The persisted owner key for an explicit owner id. Raw ids never reach disk — state.json
+ /// stores only the domain-separated SHA-256 — so tests must compare against the hash.
+ ///
+ private static string KeyOf(string ownerId) => UiOwnerResolver.ComputeWorkflowKey(ownerId);
+
+ private async Task WaitForStateAsync(Func predicate, int timeoutMs = 20_000)
+ {
+ var deadline = Stopwatch.StartNew();
+ while (deadline.ElapsedMilliseconds < timeoutMs)
+ {
+ if (predicate(ReadState()))
+ {
+ return true;
+ }
+
+ await Task.Delay(50);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Waits for , failing with the agent's own output if it exits first.
+ ///
+ ///
+ /// A child that dies during startup — a mistyped option, an unresolvable target — would otherwise
+ /// surface only as an opaque "state never reached" timeout that says nothing about the real cause.
+ ///
+ private async Task WaitForAgentStateAsync(
+ AgentRun agent, Func predicate, string because, int timeoutMs = 20_000)
+ {
+ var deadline = Stopwatch.StartNew();
+ while (deadline.ElapsedMilliseconds < timeoutMs)
+ {
+ if (predicate(ReadState()))
+ {
+ return;
+ }
+
+ if (agent.Process.HasExited)
+ {
+ Assert.Fail(
+ $"{because}, but the agent exited early with code {agent.Process.ExitCode}. Output: {await agent.Output}");
+ }
+
+ await Task.Delay(50);
+ }
+
+ Assert.Fail($"{because}, but the state was never reached within {timeoutMs} ms.");
+ }
+
+ ///
+ /// Has open the File drop-down through a real exclusive command, so the
+ /// turn and the transient UI are established by the same agent action.
+ ///
+ ///
+ /// Doing both in one command is not just convenient — it removes any window between "A owns the
+ /// turn" and "A has transient UI on screen" in which the 4 s idle grace could lapse and quietly
+ /// invalidate the test's premise. It is also what a real agent does.
+ ///
+ private async Task OpenMenuAsOwnerAsync(string ownerId)
+ {
+ var (exitCode, output) = await RunAgentAsync(ownerId, WithTarget("ui", "invoke", "File Menu"));
+ Assert.AreEqual(0, exitCode, $"the agent's menu-opening command should succeed. Output: {output}");
+ _graceWatch = Stopwatch.StartNew();
+
+ // UIA Invoke posts the click; the drop-down appears a moment later.
+ var deadline = Stopwatch.StartNew();
+ while (deadline.ElapsedMilliseconds < 3_000 && !_fixture.IsFileMenuOpen)
+ {
+ await Task.Delay(50);
+ }
+
+ Assert.IsTrue(
+ _fixture.IsFileMenuOpen,
+ "the agent's own command must have opened real transient UI for the test to be meaningful");
+ Assert.AreEqual(
+ KeyOf(ownerId), ReadState().Owner?.Key,
+ "the agent must hold the turn immediately after its exclusive command completes");
+ }
+
+ // ------------------------------------------------------------------------------ §18.3 (a)
+
+ ///
+ /// §18.3(a): a tight burst by one owner keeps its transient UI intact while a different owner's
+ /// mutation is held back.
+ ///
+ ///
+ /// The menu is opened by agent A's own coordinated command rather than directly on the fixture, so
+ /// the turn and the transient UI are established by the same action and there is no window in
+ /// which the premise could lapse. Agent B is a genuine separate process, so the foreground steal it
+ /// would perform is real.
+ ///
+ [TestMethod]
+ public async Task ATightBurstKeepsTransientMenuOpenWhileAnotherOwnerWaits()
+ {
+ await OpenMenuAsOwnerAsync(OwnerA);
+
+ // A different agent tries to act on the same desktop. Its click would take the foreground and
+ // dismiss the drop-down, so coordination must hold it until A's burst is finished.
+ var agentB = StartAgent(OwnerB, WithTarget("ui", "click", "btnInvoke"));
+
+ await WaitForAgentStateAsync(
+ agentB,
+ s => s.Waiters.Count == 1 && s.Waiters[0].Pid == agentB.Process.Id,
+ "agent B must queue behind agent A's turn instead of acting immediately");
+
+ // A's burst: observations never yield the turn, and each one refreshes nothing that would let B in.
+ for (var i = 0; i < 3; i++)
+ {
+ var (exitCode, output) = await RunAgentAsync(OwnerA, WithTarget("ui", "inspect"));
+ Assert.AreEqual(0, exitCode, $"burst step {i} should succeed. Output: {output}");
+ Assert.IsTrue(
+ _fixture.IsFileMenuOpen,
+ $"the transient menu must still be open after burst step {i}: another owner was allowed to interfere");
+ Assert.IsFalse(agentB.Process.HasExited, "agent B must still be waiting during the burst");
+ }
+
+ Assert.IsTrue(_fixture.IsFileMenuOpen, "the burst must complete with the transient UI intact");
+
+ // Releasing the turn lets B through, and its foreground steal dismisses the menu. Observing that
+ // proves the earlier assertions were real protection rather than B simply being slow.
+ Assert.AreEqual(0, await agentB.Completion, $"agent B should succeed once it gets the turn. Output: {await agentB.Output}");
+
+ var deadline = Stopwatch.StartNew();
+ while (deadline.ElapsedMilliseconds < 5_000 && _fixture.IsFileMenuOpen)
+ {
+ await Task.Delay(100);
+ }
+
+ Assert.IsFalse(
+ _fixture.IsFileMenuOpen,
+ "agent B's click should have dismissed the menu once it ran, confirming it was genuinely blocked before");
+ }
+
+ // ------------------------------------------------------------------------------ §18.3 (b)
+
+ ///
+ /// §18.3(b): a reasoning gap longer than the idle grace hands the turn to a waiting owner, and the
+ /// transient UI the first owner left behind does not survive — so its next step must replay.
+ ///
+ ///
+ /// The replay is itself a coordinated mutation, because that is the only thing a returning agent
+ /// can actually do: by then the other owner holds the turn and its own grace, so the returning
+ /// agent has to queue, wait, and reacquire before it can put its UI back. An observation cannot
+ /// stand in for that step — a non-owner's Observe is detached by design, so it neither waits nor
+ /// pins, and would leave the assertion below racing the other owner's grace.
+ ///
+ [TestMethod]
+ public async Task AReasoningGapHandsOverTheTurnAndForcesReplay()
+ {
+ await OpenMenuAsOwnerAsync(OwnerA);
+
+ var agentB = StartAgent(OwnerB, WithTarget("ui", "click", "btnInvoke"));
+ await WaitForAgentStateAsync(
+ agentB,
+ s => s.Waiters.Count == 1,
+ "agent B must queue while agent A still holds the turn");
+
+ // The reasoning gap: agent A issues nothing while its model thinks. The grace is not renewed,
+ // so ownership legitimately transfers. The grace started when A's command completed, not when B
+ // queued, so it is measured from there.
+ Assert.AreEqual(0, await agentB.Completion, $"agent B must acquire the turn after the gap. Output: {await agentB.Output}");
+ Assert.IsTrue(
+ _graceWatch!.ElapsedMilliseconds >= InteractiveDesktopScheduler.IdleGraceMs - 500,
+ $"handover must wait out the {InteractiveDesktopScheduler.IdleGraceMs} ms idle grace, "
+ + $"but took {_graceWatch.ElapsedMilliseconds} ms from agent A's last command");
+
+ Assert.IsTrue(
+ await WaitForStateAsync(s => s.Owner?.Key == KeyOf(OwnerB), timeoutMs: 5_000),
+ "the turn must transfer to agent B after agent A's grace expires");
+
+ var menuGone = Stopwatch.StartNew();
+ while (menuGone.ElapsedMilliseconds < 5_000 && _fixture.IsFileMenuOpen)
+ {
+ await Task.Delay(100);
+ }
+
+ Assert.IsFalse(
+ _fixture.IsFileMenuOpen,
+ "the transient UI must NOT survive the handover: this is exactly why an agent has to replay after a gap");
+
+ // Agent A resumes and finds a different world. Its recovery must go through coordination the
+ // same way its first step did: reopening the drop-down straight on the fixture would prove
+ // nothing here, because it bypasses the arbitration this test exists to exercise. The command
+ // below queues behind whatever agent B still holds, waits, reacquires the turn, and reopens the
+ // menu — and OpenMenuAsOwnerAsync asserts both halves of that (menu back on screen, turn owned
+ // by A again).
+ await OpenMenuAsOwnerAsync(OwnerA);
+
+ // Now that A owns the turn again its Observe pins rather than detaches, so the UI it just
+ // restored is still standing afterwards. Running the same inspect while A was a non-owner
+ // would have been detached — no ticket, no lease, nothing holding the desktop — which is why
+ // the menu could not be expected to survive it before this point.
+ var (replayExit, replayOutput) = await RunAgentAsync(OwnerA, WithTarget("ui", "inspect"));
+ Assert.AreEqual(0, replayExit, $"agent A must be able to replay after the handover. Output: {replayOutput}");
+ Assert.IsTrue(_fixture.IsFileMenuOpen, "agent A's restored transient UI must survive its own observation");
+ Assert.AreEqual(
+ KeyOf(OwnerA), ReadState().Owner?.Key,
+ "agent A must still hold the turn it reacquired");
+ }
+
+ // ------------------------------------------------------------------------------ §18.3 (c)
+
+ ///
+ /// §18.3(c): a recording pins the turn to its owner. Same-owner input continues during the
+ /// recording (a later exclusive command is not blocked by an earlier running shared one), while a
+ /// different owner's mutation waits until the recording finishes.
+ ///
+ [TestMethod]
+ public async Task RecordingPinsTheOwnerWhileSameOwnerInputContinuesAndAnotherOwnerWaits()
+ {
+ const int recordSeconds = 8;
+ var outputPath = Path.Combine(_scratchDirectory, "pinned.mp4");
+
+ // Explicit precondition: this test injects real keystrokes, so it must not inherit menu mode or
+ // a stray foreground window from whatever ran before it.
+ _fixture.CloseFileMenu();
+ Assert.IsFalse(_fixture.IsFileMenuOpen, "no drop-down may be capturing keyboard input");
+
+ var recorder = StartAgent(OwnerA, WithTarget(
+ "ui", "record", "--duration-sec", recordSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ "-o", outputPath));
+
+ await WaitForAgentStateAsync(
+ recorder,
+ s => s.Owner?.Key == KeyOf(OwnerA) && s.OwnerCommands.Any(
+ c => c.Mode == UiTurnMode.TurnShared && c.Status == UiCommandStatus.Running),
+ "the recording must register as a running shared command owned by agent A");
+
+ // A different owner's mutation must not interleave with the recording.
+ var agentB = StartAgent(OwnerB, WithTarget("ui", "click", "btnInvoke"));
+ await WaitForAgentStateAsync(
+ agentB,
+ s => s.Waiters.Any(w => w.Pid == agentB.Process.Id),
+ "agent B must queue behind the recording owner");
+
+ // Same-owner input proceeds while the recording runs: an earlier running TurnShared command does
+ // not block a later DesktopExclusive one from the same owner (§10.3).
+ //
+ // A UIA Invoke is used rather than synthetic keystrokes because its effect is observable
+ // deterministically (the button handler sets the result box) and does not depend on desktop-wide
+ // keyboard focus, which other tests in this class legitimately disturb. Keystroke ordering itself
+ // is covered by the send-keys coverage in RealUiAutomationTests.
+ var inputTimer = Stopwatch.StartNew();
+ var (actionExit, actionOutput) = await RunAgentAsync(OwnerA, WithTarget("ui", "invoke", "Click Me"));
+ inputTimer.Stop();
+
+ Assert.AreEqual(0, actionExit, $"same-owner input must proceed during the recording. Output: {actionOutput}");
+ Assert.IsFalse(recorder.Process.HasExited, "the recording must still be running when same-owner input completes");
+ Assert.IsFalse(agentB.Process.HasExited, "agent B must still be waiting while the recording owner is active");
+
+ Assert.IsTrue(
+ inputTimer.ElapsedMilliseconds < recordSeconds * 1000,
+ $"same-owner input waited {inputTimer.ElapsedMilliseconds} ms, which suggests it was blocked by the recording");
+
+ // The control updates on the app's UI thread after the invoke is dispatched, so poll briefly
+ // rather than sampling once and racing the message pump.
+ var effectWatch = Stopwatch.StartNew();
+ var result = _fixture.OnUiThread(() => _fixture.ResultBox.Text);
+ while (effectWatch.ElapsedMilliseconds < 5_000 && result != "clicked")
+ {
+ await Task.Delay(100);
+ result = _fixture.OnUiThread(() => _fixture.ResultBox.Text);
+ }
+
+ Assert.AreEqual("clicked", result,
+ "the same-owner command must have really acted on the app while the recording was running");
+
+ Assert.AreEqual(0, await recorder.Completion, $"the recording should succeed. Output: {await recorder.Output}");
+ Assert.IsTrue(File.Exists(outputPath), "the recording must have produced its output file");
+
+ Assert.AreEqual(0, await agentB.Completion, $"agent B should run once the recording releases the turn. Output: {await agentB.Output}");
+ Assert.IsTrue(
+ agentB.Process.ExitTime >= recorder.Process.ExitTime.AddMilliseconds(-250),
+ "agent B must not have completed before the recording released the turn");
+ }
+
+ ///
+ /// Backstop for the fixture window: already disposes it after every test, so
+ /// this only matters if the framework tears the class down without running cleanup.
+ ///
+ public void Dispose()
+ {
+ _fixture?.Dispose();
+ _fixture = null!;
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs
new file mode 100644
index 000000000..b63d072ec
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs
@@ -0,0 +1,989 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Deterministic, file-free coverage of the cooperative-turn state machine
+/// (, issue #764). Every rule from spec sections 10.1–10.7 is
+/// asserted here against in-memory state and a fake clock, so scheduling bugs surface without needing
+/// a desktop, real processes, or timing luck.
+///
+[TestClass]
+public class InteractiveDesktopSchedulerTests
+{
+ private FakeClock _clock = null!;
+ private FakeLivenessProbe _probe = null!;
+ private InteractiveDesktopScheduler _scheduler = null!;
+
+ private static readonly UiOwnerIdentity OwnerA = new(UiOwnerKind.Workflow, "aaaa");
+ private static readonly UiOwnerIdentity OwnerB = new(UiOwnerKind.Workflow, "bbbb");
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _clock = new FakeClock();
+ _probe = new FakeLivenessProbe();
+ _scheduler = new InteractiveDesktopScheduler(_clock);
+ }
+
+ private UiParticipantIdentity Participant(int pid, string operation = "ui click")
+ {
+ _probe.Alive.Add((pid, pid));
+ return new UiParticipantIdentity(pid, pid, operation);
+ }
+
+ // ---------------------------------------------------------------- identity and turn acquisition
+
+ [TestMethod]
+ public void BeginParticipating_OnFreeDesktop_StartsNewTurnAndRunsImmediately()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+
+ var result = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiAdmission.OwnerCommandRunning, result.Admission);
+ Assert.AreEqual(UiTurnAction.New, result.TurnAction);
+ Assert.AreEqual(OwnerA.Key, state.Owner!.Key);
+ Assert.AreEqual(1, state.TurnId);
+ Assert.AreEqual(1, state.OwnerCommands.Count);
+ Assert.AreEqual(UiCommandStatus.Running, state.OwnerCommands[0].Status);
+ }
+
+ [TestMethod]
+ public void BeginParticipating_SameOwner_JoinsExistingTurnAsContinuation()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared);
+
+ var result = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiTurnAction.Continuation, result.TurnAction);
+ Assert.AreEqual(1, state.TurnId, "joining an owned turn must not start a new one");
+ Assert.AreEqual(2, state.OwnerCommands.Count);
+ }
+
+ [TestMethod]
+ public void BeginParticipating_OtherOwner_QueuesGloballyWithTicket()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+
+ var result = _scheduler.BeginParticipating(
+ state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiAdmission.GlobalWaiter, result.Admission);
+ Assert.AreEqual(UiTurnAction.Queued, result.TurnAction);
+ Assert.AreEqual(1, result.QueuePosition);
+ Assert.AreEqual(1, state.Waiters.Count);
+ Assert.AreEqual(UiTurnMode.DesktopExclusive, state.Waiters[0].Mode,
+ "the requested mode must be persisted so any process can promote the waiter");
+ }
+
+ // ---------------------------------------------------------------------------- forward barrier
+
+ [TestMethod]
+ public void Barrier_RunningDesktopExclusive_BlocksLaterTurnSharedAndExclusive()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+
+ var laterShared = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(101, "ui record"), UiTurnMode.TurnShared);
+ var laterExclusive = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(102), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiAdmission.OwnerCommandWaiting, laterShared.Admission);
+ Assert.AreEqual(UiAdmission.OwnerCommandWaiting, laterExclusive.Admission);
+ }
+
+ [TestMethod]
+ public void Barrier_WaitingDesktopExclusive_AlsoBlocksLaterCommands()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ // Ticket 1 exclusive runs; ticket 2 exclusive waits behind it; ticket 3 must wait behind ticket 2
+ // even though ticket 2 has not started — a pending barrier blocks just like a running one.
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive);
+
+ var third = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(102, "ui record"), UiTurnMode.TurnShared);
+
+ Assert.AreEqual(UiAdmission.OwnerCommandWaiting, third.Admission);
+ Assert.AreEqual(UiCommandStatus.Waiting, state.OwnerCommands[1].Status);
+ }
+
+ [TestMethod]
+ public void Barrier_EarlierRunningTurnShared_ContinuesAcrossALaterExclusive()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var recorder = Participant(100, "ui record");
+ _scheduler.BeginParticipating(state, _probe, OwnerA, recorder, UiTurnMode.TurnShared);
+
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive);
+
+ var recording = InteractiveDesktopScheduler.FindOwnerCommand(state, recorder)!;
+ Assert.AreEqual(UiCommandStatus.Running, recording.Status,
+ "a recording that already started keeps running while same-owner input takes the desktop");
+ }
+
+ [TestMethod]
+ public void Barrier_MultipleTurnShared_OverlapWhenNoExclusiveIsPending()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var first = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared);
+ var second = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(101, "ui record"), UiTurnMode.TurnShared);
+
+ Assert.AreEqual(UiAdmission.OwnerCommandRunning, first.Admission);
+ Assert.AreEqual(UiAdmission.OwnerCommandRunning, second.Admission);
+ }
+
+ [TestMethod]
+ public void Barrier_ReleasesNextCommandInTicketOrderWhenTheBarrierCompletes()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var first = Participant(100);
+ var second = Participant(101);
+ var third = Participant(102);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, first, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, third, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, second, UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((first.ProcessId, first.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, first, OwnerA, renewGrace: true);
+
+ // 'third' was admitted before 'second', so it owns the smaller ticket and runs first.
+ Assert.AreEqual(UiCommandStatus.Running,
+ InteractiveDesktopScheduler.FindOwnerCommand(state, third)!.Status);
+ Assert.AreEqual(UiCommandStatus.Waiting,
+ InteractiveDesktopScheduler.FindOwnerCommand(state, second)!.Status);
+ }
+
+ // -------------------------------------------------------------------------------- observations
+
+ [TestMethod]
+ public void BeginObserve_NonOwner_RunsDetachedWithoutTouchingTheQueue()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+
+ var result = _scheduler.BeginObserve(state, _probe, OwnerB, Participant(200, "ui inspect"));
+
+ Assert.AreEqual(UiAdmission.Detached, result.Admission);
+ Assert.AreEqual(UiTurnAction.Detached, result.TurnAction);
+ Assert.AreEqual(1, state.OwnerCommands.Count, "a detached observation must not register");
+ Assert.AreEqual(0, state.Waiters.Count);
+ }
+
+ [TestMethod]
+ public void BeginObserve_CurrentOwner_PinsTheTurnAndRunsImmediately()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+
+ var observation = Participant(101, "ui inspect");
+ var result = _scheduler.BeginObserve(state, _probe, OwnerA, observation);
+
+ Assert.AreEqual(UiAdmission.OwnerCommandRunning, result.Admission);
+ Assert.IsNull(InteractiveDesktopScheduler.FindOwnerCommand(state, observation)!.Ticket,
+ "observations carry no ticket, so they never act as a barrier");
+
+ // The pin must survive past the original grace: another owner cannot take the desktop while the
+ // observation is still reading transient UI.
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs + 1_000);
+ _scheduler.Normalize(state, _probe);
+ Assert.AreEqual(OwnerA.Key, state.Owner!.Key);
+ }
+
+ [TestMethod]
+ public void CompletingAnObservation_StartsAFreshGrace()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+
+ _clock.Advance(3_000);
+ var observation = Participant(101, "ui inspect");
+ _scheduler.BeginObserve(state, _probe, OwnerA, observation);
+ _clock.Advance(5_000);
+ _probe.Alive.Remove((observation.ProcessId, observation.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, observation, OwnerA, renewGrace: true);
+
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs - 100);
+ _scheduler.Normalize(state, _probe);
+ Assert.AreEqual(OwnerA.Key, state.Owner!.Key, "the observation's completion renewed the grace");
+ }
+
+ // ------------------------------------------------------------------------- expiry and handoff
+
+ [TestMethod]
+ public void BeginParticipating_AfterAnotherOwnersGraceExpired_ReportsHandoffAfterIdle()
+ {
+ // Spec §16 advertises a `handoff-after-idle` turn action. Taking over a turn that normalization
+ // just released is not the same as finding a free desktop, and the two must stay distinguishable
+ // in telemetry.
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs);
+
+ var result = _scheduler.BeginParticipating(
+ state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiAdmission.OwnerCommandRunning, result.Admission);
+ Assert.AreEqual(UiTurnAction.HandoffAfterIdle, result.TurnAction,
+ "the desktop was not free — another workflow's idle grace had just run out");
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key);
+ }
+
+ [TestMethod]
+ public void BeginParticipating_AfterItsOwnGraceExpired_ReportsNewRatherThanHandoff()
+ {
+ // The same workflow reclaiming its own lapsed turn took nothing from anybody, so it is a new
+ // turn. Reporting a handoff here would make the value meaningless.
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs);
+
+ var result = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiTurnAction.New, result.TurnAction);
+ }
+
+ [TestMethod]
+ public void BeginParticipating_OnAGenuinelyFreeDesktop_ReportsNew()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+
+ var result = _scheduler.BeginParticipating(
+ state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiTurnAction.New, result.TurnAction,
+ "no previous owner existed, so nothing was handed off");
+ }
+
+ // ------------------------------------------------------------------------------- turn stamping
+
+ [TestMethod]
+ public void ClaimingATurn_StampsTheTurnStartTick()
+ {
+ // The turn-age telemetry bucket measures how long the workflow has held the desktop, so the
+ // start tick must be written by whoever claims the turn, not derived from a per-command wait.
+ _clock.Advance(5_000);
+ var state = InteractiveDesktopState.CreateFresh();
+
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(_clock.NowTicks64, state.TurnStartedTick64);
+ }
+
+ [TestMethod]
+ public void JoiningAnExistingTurn_KeepsTheOriginalStartTick()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared);
+ var claimedAt = state.TurnStartedTick64;
+
+ _clock.Advance(30_000);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(claimedAt, state.TurnStartedTick64,
+ "a continuation joins the turn already in progress; its age keeps accumulating");
+ }
+
+ [TestMethod]
+ public void Handoff_RestampsTheTurnStartTickForThePromotedOwner()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs);
+ _scheduler.Normalize(state, _probe);
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key);
+ Assert.AreEqual(_clock.NowTicks64, state.TurnStartedTick64,
+ "the promoted owner's turn starts now — it must not inherit the previous owner's age");
+ }
+
+ [TestMethod]
+ public void ExpiringAnIdleTurn_ClearsTheTurnStartTick()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs);
+ _scheduler.Normalize(state, _probe);
+
+ Assert.IsNull(state.Owner);
+ Assert.AreEqual(0, state.TurnStartedTick64, "an unowned desktop has no turn to age");
+ }
+
+ [TestMethod]
+ public void IdleTurn_ExpiresAfterExactlyFourSeconds()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs - 1);
+ _scheduler.Normalize(state, _probe);
+ Assert.IsNotNull(state.Owner, "the turn is still reserved inside the grace");
+
+ _clock.Advance(1);
+ _scheduler.Normalize(state, _probe);
+ Assert.IsNull(state.Owner, "the turn is released once the grace elapses");
+ }
+
+ [TestMethod]
+ public void ActiveOwner_HasNoHardDeadline()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive);
+
+ _clock.Advance(10 * 60 * 1_000);
+ _scheduler.Normalize(state, _probe);
+
+ Assert.AreEqual(OwnerA.Key, state.Owner!.Key, "a live owner command is never preempted");
+ Assert.AreEqual(1, state.Waiters.Count);
+ }
+
+ [TestMethod]
+ public void WaitingOwnerCommand_CountsAsActivityAndBlocksHandoff()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive);
+
+ _clock.Advance(60_000);
+ _scheduler.Normalize(state, _probe);
+
+ Assert.AreEqual(OwnerA.Key, state.Owner!.Key);
+ }
+
+ [TestMethod]
+ public void Handoff_PromotesOldestLiveWaiterAndIncrementsTurnId()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ var waiterB = Participant(200);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, waiterB, UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs);
+ _scheduler.Normalize(state, _probe);
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key);
+ Assert.AreEqual(2, state.TurnId);
+ Assert.AreEqual(0, state.Waiters.Count);
+ Assert.AreEqual(UiCommandStatus.Running,
+ InteractiveDesktopScheduler.FindOwnerCommand(state, waiterB)!.Status);
+ }
+
+ [TestMethod]
+ public void Handoff_SkipsDeadWaitersAndPicksTheOldestLiveTicket()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+
+ var deadWaiter = Participant(200);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, deadWaiter, UiTurnMode.DesktopExclusive);
+ var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc");
+ var liveWaiter = Participant(300);
+ _scheduler.BeginParticipating(state, _probe, ownerC, liveWaiter, UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((deadWaiter.ProcessId, deadWaiter.StartTicksUtc));
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs);
+ _scheduler.Normalize(state, _probe);
+
+ Assert.AreEqual("cccc", state.Owner!.Key);
+ }
+
+ [TestMethod]
+ public void ExpiredOwner_ReRegisteringGoesToTheBackOfTheQueue()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs);
+
+ var result = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiAdmission.GlobalWaiter, result.Admission,
+ "the previous owner must not race ahead of a waiter that was already queued");
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key);
+ }
+
+ // ------------------------------------------------------------------------------- grace rules
+
+ [TestMethod]
+ public void NonCancelledFailure_RenewsTheGrace()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+
+ // A command that ran and returned a non-zero exit code still renews: the workflow is alive and
+ // its next command is likely a retry.
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs - 1);
+ _scheduler.Normalize(state, _probe);
+ Assert.IsNotNull(state.Owner);
+ }
+
+ [TestMethod]
+ public void Cancellation_DoesNotRenewTheGrace()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false);
+
+ Assert.IsNull(state.Owner, "a cancelled command leaves no reservation behind");
+ }
+
+ [TestMethod]
+ public void AnonymousOwner_ReceivesNoGraceAndHandsOffImmediately()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon");
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, anonymous, actor, UiTurnMode.DesktopExclusive);
+ var waiter = Participant(200);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, waiter, UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, anonymous, renewGrace: true);
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key,
+ "a one-command owner has no shell that could issue a follow-up, so it hands off at once");
+ }
+
+ // ------------------------------------------------------------------------------ pruning rules
+
+ [TestMethod]
+ public void DeadParticipants_ArePrunedAndReleaseTheTurn()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+
+ // The process was killed: Windows deleted its DeleteOnClose lease, so it is provably gone.
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.Normalize(state, _probe);
+
+ Assert.AreEqual(0, state.OwnerCommands.Count);
+ Assert.IsNull(state.Owner, "a crash does not renew the grace, so the turn is released at once");
+ }
+
+ [TestMethod]
+ public void SuspendedLiveWaiter_IsNeverPrunedAndKeepsTheHeadOfTheQueue()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+
+ // A suspended process still holds its lease. There are no heartbeats, so nothing can mistake it
+ // for dead and nothing can overtake it (spec §19).
+ var suspended = Participant(200);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, suspended, UiTurnMode.DesktopExclusive);
+ var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc");
+ _scheduler.BeginParticipating(state, _probe, ownerC, Participant(300), UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false);
+ _clock.Advance(60_000);
+ _scheduler.Normalize(state, _probe);
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key,
+ "the suspended waiter is alive, so it is promoted rather than skipped");
+ Assert.AreEqual(1, state.Waiters.Count, "the later waiter stays queued behind it");
+ }
+
+ // -------------------------------------------------------------------------------- queue limits
+
+ [TestMethod]
+ public void QueueCap_AppliesOnlyAfterDeadWaitersArePruned()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+
+ var queued = new List();
+ for (var i = 0; i < InteractiveDesktopScheduler.MaxGlobalWaiters; i++)
+ {
+ var waiter = Participant(1_000 + i);
+ queued.Add(waiter);
+ _scheduler.BeginParticipating(
+ state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, $"owner{i}"),
+ waiter, UiTurnMode.DesktopExclusive);
+ }
+
+ Assert.ThrowsExactly(() => _scheduler.BeginParticipating(
+ state, _probe, OwnerB, Participant(9_999), UiTurnMode.DesktopExclusive));
+
+ // Once one waiter dies the cap frees up again, because it counts live waiters only.
+ _probe.Alive.Remove((queued[0].ProcessId, queued[0].StartTicksUtc));
+ var accepted = _scheduler.BeginParticipating(
+ state, _probe, OwnerB, Participant(9_998), UiTurnMode.DesktopExclusive);
+ Assert.AreEqual(UiAdmission.GlobalWaiter, accepted.Admission);
+ }
+
+ [TestMethod]
+ public void QueueCapFailure_LeavesNoStateEntryBehind()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+ for (var i = 0; i < InteractiveDesktopScheduler.MaxGlobalWaiters; i++)
+ {
+ _scheduler.BeginParticipating(
+ state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, $"owner{i}"),
+ Participant(1_000 + i), UiTurnMode.DesktopExclusive);
+ }
+
+ var rejected = Participant(9_999);
+ try
+ {
+ _scheduler.BeginParticipating(state, _probe, OwnerB, rejected, UiTurnMode.DesktopExclusive);
+ }
+ catch (UiCoordinationException ex)
+ {
+ Assert.AreEqual(UiCoordinationErrorCodes.QueueCapacityExceeded, ex.Code);
+ }
+
+ Assert.IsNull(InteractiveDesktopScheduler.FindWaiter(state, rejected));
+ Assert.IsNull(InteractiveDesktopScheduler.FindOwnerCommand(state, rejected));
+ }
+
+ // --------------------------------------------------------------- same-owner queue absorption
+
+ [TestMethod]
+ public void PromotedOwner_AbsorbsItsOtherQueuedCommandsInTicketOrder()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+
+ var firstB = Participant(200);
+ var secondB = Participant(201);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, firstB, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, secondB, UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false);
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key);
+ Assert.AreEqual(0, state.Waiters.Count,
+ "with no other owner queued, the whole prefix belongs to B and is absorbed");
+ Assert.AreEqual(UiCommandStatus.Running,
+ InteractiveDesktopScheduler.FindOwnerCommand(state, firstB)!.Status);
+ Assert.AreEqual(UiCommandStatus.Waiting,
+ InteractiveDesktopScheduler.FindOwnerCommand(state, secondB)!.Status,
+ "the second command still queues behind its own owner's barrier");
+ }
+
+ // --------------------------------------------------------------- same-owner queue absorption
+
+ [TestMethod]
+ public void PromotedOwner_AbsorbsOnlyItsContiguousPrefixAtTheQueueHead()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+
+ // Queue order B, B, C: nothing separates the two B commands, so both may be absorbed.
+ var firstB = Participant(200);
+ var secondB = Participant(201);
+ var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc");
+ var firstC = Participant(300);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, firstB, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, secondB, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, ownerC, firstC, UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false);
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key);
+ Assert.IsNotNull(InteractiveDesktopScheduler.FindOwnerCommand(state, firstB));
+ Assert.IsNotNull(InteractiveDesktopScheduler.FindOwnerCommand(state, secondB));
+ Assert.IsNotNull(InteractiveDesktopScheduler.FindWaiter(state, firstC),
+ "the other owner's command must stay in the global queue");
+ }
+
+ [TestMethod]
+ public void PromotedOwner_StopsAbsorbingAtTheFirstDifferentOwner()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+
+ // Queue order B, C, B. Absorbing the trailing B would let it run before C even though C has the
+ // older ticket, which would break strict global FIFO.
+ var firstB = Participant(200);
+ var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc");
+ var firstC = Participant(300);
+ var secondB = Participant(201);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, firstB, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, ownerC, firstC, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, secondB, UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false);
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key);
+ Assert.IsNotNull(InteractiveDesktopScheduler.FindOwnerCommand(state, firstB),
+ "the head of the queue belongs to the promoted owner and is absorbed");
+ Assert.IsNull(InteractiveDesktopScheduler.FindOwnerCommand(state, secondB),
+ "absorption must stop at the first different owner so C keeps its earlier place");
+ Assert.IsNotNull(InteractiveDesktopScheduler.FindWaiter(state, secondB));
+ Assert.IsNotNull(InteractiveDesktopScheduler.FindWaiter(state, firstC));
+
+ // And C really does get the turn next, ahead of B's second command.
+ _probe.Alive.Remove((firstB.ProcessId, firstB.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, firstB, OwnerA, renewGrace: false);
+ Assert.AreEqual("cccc", state.Owner!.Key);
+ }
+
+ // -------------------------------------------------------------------------- ticket monotonicity
+
+ [TestMethod]
+ public void Tickets_AreGloballyMonotonicAcrossOwnersAndModes()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var seen = new List();
+
+ seen.Add(_scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive).Ticket!.Value);
+ seen.Add(_scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(101, "ui record"), UiTurnMode.TurnShared).Ticket!.Value);
+ seen.Add(_scheduler.BeginParticipating(
+ state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive).Ticket!.Value);
+
+ CollectionAssert.AreEqual(seen.OrderBy(t => t).ToList(), seen);
+ Assert.AreEqual(seen.Count, seen.Distinct().Count());
+ }
+
+ // ---------------------------------------------------------------- prior-boot deadline recovery
+
+ [TestMethod]
+ public void PriorBootDeadline_ExpiresImmediatelyInsteadOfStrandingTheTurn()
+ {
+ // Environment.TickCount64 restarts at reboot. A state file written after days of uptime carries
+ // a deadline far beyond the new uptime; without clamping, the owner that died with the previous
+ // boot would hold the desktop until the machine had been up just as long again.
+ var state = InteractiveDesktopState.CreateFresh();
+ state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = OwnerA.Key };
+ state.TurnId = 7;
+ state.IdleExpiresTick64 = _clock.NowTicks64 + (long)TimeSpan.FromDays(5).TotalMilliseconds;
+
+ var changed = _scheduler.Normalize(state, _probe);
+
+ Assert.IsTrue(changed, "clamping a prior-boot deadline is a state change that must be published");
+ Assert.IsNull(state.Owner, "the stranded turn must be released on the very next normalization");
+ }
+
+ [TestMethod]
+ public void PriorBootDeadline_PromotesAWaitingOwnerImmediately()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = OwnerA.Key };
+ state.TurnId = 7;
+ state.NextTicket = 1;
+ state.IdleExpiresTick64 = _clock.NowTicks64 + (long)TimeSpan.FromDays(5).TotalMilliseconds;
+
+ var waiter = Participant(300);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, waiter, UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key, "the waiting owner takes the abandoned turn at once");
+ Assert.AreEqual(UiCommandStatus.Running,
+ InteractiveDesktopScheduler.FindOwnerCommand(state, waiter)!.Status);
+ }
+
+ [TestMethod]
+ public void ADeadlineWithinTheGraceIsNotTreatedAsPriorBoot()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+
+ // Exactly the deadline a normal completion writes: now + IdleGraceMs. It must survive.
+ _scheduler.Normalize(state, _probe);
+
+ Assert.IsNotNull(state.Owner, "an ordinary in-grace deadline must never be mistaken for a stale one");
+ }
+
+ // -------------------------------------------------------- the idle deadline belongs to the owner
+
+ [TestMethod]
+ public void AForeignCompletionCannotRevokeTheCurrentOwnersGrace()
+ {
+ // An anonymous global waiter that gets cancelled or fails completes under its own identity.
+ // Setting the deadline from that path would hand owner B's turn away instantly.
+ var state = InteractiveDesktopState.CreateFresh();
+ var ownerBActor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, ownerBActor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((ownerBActor.ProcessId, ownerBActor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, ownerBActor, OwnerB, renewGrace: true);
+
+ var graceBefore = state.IdleExpiresTick64;
+ Assert.IsNotNull(state.Owner);
+
+ var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon");
+ var stranger = Participant(200);
+ _scheduler.BeginParticipating(state, _probe, anonymous, stranger, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((stranger.ProcessId, stranger.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, stranger, anonymous, renewGrace: false);
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key, "owner B must still hold the turn");
+ Assert.AreEqual(graceBefore, state.IdleExpiresTick64,
+ "a non-owner completion must not touch the current owner's idle deadline");
+ }
+
+ [TestMethod]
+ public void AForeignCompletionCannotExtendTheCurrentOwnersGrace()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var ownerBActor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, ownerBActor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((ownerBActor.ProcessId, ownerBActor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, ownerBActor, OwnerB, renewGrace: true);
+ var graceBefore = state.IdleExpiresTick64;
+
+ _clock.Advance(1_000);
+
+ // A queued command belonging to a *different* owner finishes normally.
+ var stranger = Participant(200);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, stranger, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((stranger.ProcessId, stranger.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, stranger, OwnerA, renewGrace: true);
+
+ Assert.AreEqual(graceBefore, state.IdleExpiresTick64,
+ "only the current owner's own commands may extend its grace");
+ }
+
+ [TestMethod]
+ public void TheCurrentOwnersOwnCompletionStillRenewsItsGrace()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var first = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, first, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((first.ProcessId, first.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, first, OwnerA, renewGrace: true);
+ var firstDeadline = state.IdleExpiresTick64;
+
+ _clock.Advance(1_000);
+ var second = Participant(101);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, second, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((second.ProcessId, second.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, second, OwnerA, renewGrace: true);
+
+ Assert.IsTrue(state.IdleExpiresTick64 > firstDeadline,
+ "a burst from the owner keeps renewing its own grace");
+ }
+
+
+ [TestMethod]
+ public void WorkflowOwner_KeepsFourSecondGraceAfterCompletion()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+
+ Assert.AreEqual(_clock.NowTicks64 + InteractiveDesktopScheduler.IdleGraceMs, state.IdleExpiresTick64);
+ Assert.AreEqual(OwnerA.Key, state.Owner!.Key);
+ }
+
+ [TestMethod]
+ public void AnonymousOwner_SetsDeadlineToNowOnCompletion()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon-a");
+ var actor = Participant(100);
+
+ _scheduler.BeginParticipating(state, _probe, anonymous, actor, UiTurnMode.DesktopExclusive);
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, anonymous, renewGrace: true);
+
+ Assert.IsNull(state.Owner, "normalization should expire the now-deadline immediately");
+ Assert.AreEqual(0, state.IdleExpiresTick64);
+ }
+
+ [TestMethod]
+ public void UiOwnerResolver_NoWorkflowId_MintsDistinctAnonymousOwners()
+ {
+ var previous = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable);
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, null);
+ try
+ {
+ var resolver = new UiOwnerResolver();
+ var first = resolver.Resolve();
+ var second = resolver.Resolve();
+
+ Assert.AreEqual(UiOwnerKind.Anonymous, first.Kind);
+ Assert.AreEqual(UiOwnerKind.Anonymous, second.Kind);
+ Assert.AreNotEqual(first.Key, second.Key);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, previous);
+ }
+ }
+
+ [TestMethod]
+ public void AnonymousTurnShared_BlocksDifferentAnonymousDesktopExclusive()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var recordingOwner = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon-record");
+ var clickOwner = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon-click");
+
+ _scheduler.BeginParticipating(state, _probe, recordingOwner, Participant(100, "ui record"), UiTurnMode.TurnShared);
+ var click = _scheduler.BeginParticipating(
+ state, _probe, clickOwner, Participant(200, "ui click"), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiAdmission.GlobalWaiter, click.Admission);
+ Assert.AreEqual(recordingOwner.Key, state.Owner!.Key);
+ }
+
+ [TestMethod]
+ public void SameWorkflowTurnSharedAndDesktopExclusiveOverlap()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+
+ var record = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared);
+ var click = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(101, "ui click"), UiTurnMode.DesktopExclusive);
+
+ Assert.AreEqual(UiAdmission.OwnerCommandRunning, record.Admission);
+ Assert.AreEqual(UiAdmission.OwnerCommandRunning, click.Admission);
+ Assert.AreEqual(0, state.Waiters.Count);
+ }
+
+ [TestMethod]
+ public void CurrentOwnerAffinityBeatsForeignWaiterUntilOwnerYields()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var first = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, first, UiTurnMode.DesktopExclusive);
+ var foreignB = Participant(200);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, foreignB, UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((first.ProcessId, first.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, first, OwnerA, renewGrace: true);
+
+ var returning = _scheduler.BeginParticipating(
+ state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive);
+ Assert.AreEqual(UiAdmission.OwnerCommandRunning, returning.Admission,
+ "same workflow continuation joins during grace instead of queueing behind a foreign waiter");
+
+ var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc");
+ var foreignC = Participant(300);
+ _scheduler.BeginParticipating(state, _probe, ownerC, foreignC, UiTurnMode.DesktopExclusive);
+
+ foreach (var command in state.OwnerCommands.ToArray())
+ {
+ _probe.Alive.Remove((command.Pid, command.ProcessStartTicksUtc));
+ _scheduler.CompleteCommand(
+ state,
+ _probe,
+ new UiParticipantIdentity(command.Pid, command.ProcessStartTicksUtc, command.Operation),
+ OwnerA,
+ renewGrace: false);
+ }
+
+ Assert.AreEqual(OwnerB.Key, state.Owner!.Key, "foreign waiters remain FIFO after the current owner yields");
+ Assert.IsNotNull(InteractiveDesktopScheduler.FindWaiter(state, foreignC));
+ }
+
+ [TestMethod]
+ public void PersistedGraceSurvivesWithoutLiveLease()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var actor = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive);
+
+ _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc));
+ _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true);
+ _scheduler.Normalize(state, _probe);
+
+ Assert.AreEqual(OwnerA.Key, state.Owner!.Key);
+ Assert.AreEqual(0, state.OwnerCommands.Count);
+ }
+
+
+ private sealed class FakeClock : IMonotonicClock
+ {
+ private long _ticks = 1_000_000;
+
+ public long NowTicks64 => _ticks;
+
+ public DateTimeOffset UtcNow { get; private set; } = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ public void Advance(long milliseconds)
+ {
+ _ticks += milliseconds;
+ UtcNow = UtcNow.AddMilliseconds(milliseconds);
+ }
+ }
+
+ ///
+ /// Lease-backed liveness, faked. Membership in stands in for "holds its
+ /// DeleteOnClose lease"; there is deliberately no timestamp or heartbeat concept to fake.
+ ///
+ private sealed class FakeLivenessProbe : ICoordinationLivenessProbe
+ {
+ public HashSet<(int Pid, long Start)> Alive { get; } = [];
+
+ public bool IsParticipantLive(int processId, long startTicksUtc)
+ => Alive.Contains((processId, startTicksUtc));
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs
new file mode 100644
index 000000000..065274d40
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs
@@ -0,0 +1,265 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// The scheduler half of push-based waiting: which participants a transition makes runnable, and how
+/// many times it asks the OS whether a process is still alive.
+///
+///
+/// Waking the right processes is what replaces polling, and the set is derived from the state rather
+/// than from each transition, so these tests pin the derivation rather than any one code path. The
+/// probe counts matter for the same reason the polling did: at a deep queue the coordinator was
+/// opening a process handle per waiter per transaction, which is work that scales with exactly the
+/// thing that is already under pressure.
+///
+[TestClass]
+public class InteractiveDesktopSignalSchedulingTests
+{
+ private readonly CountingLivenessProbe _probe = new();
+ private InteractiveDesktopScheduler _scheduler = null!;
+ private TestClock _clock = null!;
+
+ private static readonly UiOwnerIdentity OwnerA = new(UiOwnerKind.Workflow, "aaaa");
+ private static readonly UiOwnerIdentity OwnerB = new(UiOwnerKind.Workflow, "bbbb");
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _clock = new TestClock();
+ _scheduler = new InteractiveDesktopScheduler(_clock);
+ }
+
+ private UiParticipantIdentity Participant(int pid, string operation = "ui click")
+ {
+ _probe.Alive.Add((pid, pid));
+ return new UiParticipantIdentity(pid, pid, operation);
+ }
+
+ // --------------------------------------------------------------- who a transition makes runnable
+
+ [TestMethod]
+ public void CompletingTheOwnersLastCommandMakesTheNextQueuedOwnerRunnable()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var a = Participant(100);
+ var b = Participant(200);
+
+ _scheduler.BeginParticipating(state, _probe, OwnerA, a, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, b, UiTurnMode.DesktopExclusive);
+
+ var before = InteractiveDesktopScheduler.RunnableParticipants(state);
+ Assert.IsTrue(before.Contains((100, 100)), "the first owner runs immediately");
+ Assert.IsFalse(before.Contains((200, 200)), "the second owner is queued behind it");
+
+ _scheduler.CompleteCommand(state, _probe, a, OwnerA, renewGrace: false);
+
+ var after = InteractiveDesktopScheduler.RunnableParticipants(state);
+ var newlyRunnable = after.Except(before).ToList();
+
+ Assert.HasCount(1, newlyRunnable, "exactly the promoted waiter becomes runnable");
+ Assert.AreEqual((200, 200), newlyRunnable[0]);
+ }
+
+ [TestMethod]
+ public void ReleasingABarrierMakesEveryBlockedCommandOfThatOwnerRunnable()
+ {
+ // The case a per-transition signal list would be most likely to get wrong: one completion can
+ // unblock several commands at once, and waking only the first would leave the rest asleep until
+ // their backstop fired.
+ var state = InteractiveDesktopState.CreateFresh();
+ var barrier = Participant(100);
+ var shared1 = Participant(201, "ui record");
+ var shared2 = Participant(202, "ui record");
+
+ _scheduler.BeginParticipating(state, _probe, OwnerA, barrier, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, shared1, UiTurnMode.TurnShared);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, shared2, UiTurnMode.TurnShared);
+
+ var before = InteractiveDesktopScheduler.RunnableParticipants(state);
+ Assert.IsFalse(before.Contains((201, 201)), "both shared commands wait behind the barrier");
+ Assert.IsFalse(before.Contains((202, 202)));
+
+ _scheduler.CompleteCommand(state, _probe, barrier, OwnerA, renewGrace: true);
+
+ var newlyRunnable = InteractiveDesktopScheduler.RunnableParticipants(state).Except(before).ToList();
+ CollectionAssert.AreEquivalent(
+ new[] { (201, 201L), (202, 202L) },
+ newlyRunnable,
+ "every command the barrier released must be woken, not just the first");
+ }
+
+ [TestMethod]
+ public void PruningADeadOwnerMakesTheQueuedWaiterRunnable()
+ {
+ // Nobody publishes anything when a process is killed, so the promotion happens inside whichever
+ // participant normalizes next — and that participant must wake the winner.
+ var state = InteractiveDesktopState.CreateFresh();
+ var dead = Participant(100);
+ var waiting = Participant(200);
+
+ _scheduler.BeginParticipating(state, _probe, OwnerA, dead, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, waiting, UiTurnMode.DesktopExclusive);
+
+ var before = InteractiveDesktopScheduler.RunnableParticipants(state);
+ _probe.Alive.Remove((100, 100));
+
+ Assert.IsTrue(_scheduler.Normalize(state, _probe), "the dead owner must be pruned");
+
+ var newlyRunnable = InteractiveDesktopScheduler.RunnableParticipants(state).Except(before).ToList();
+ Assert.HasCount(1, newlyRunnable);
+ Assert.AreEqual((200, 200), newlyRunnable[0]);
+ }
+
+ [TestMethod]
+ public void AnIdleGraceExpiringMakesTheWaiterRunnable()
+ {
+ var state = InteractiveDesktopState.CreateFresh();
+ var first = Participant(100);
+ var waiting = Participant(200);
+
+ _scheduler.BeginParticipating(state, _probe, OwnerA, first, UiTurnMode.DesktopExclusive);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, waiting, UiTurnMode.DesktopExclusive);
+ _scheduler.CompleteCommand(state, _probe, first, OwnerA, renewGrace: true);
+
+ // Grace still running: the waiter stays queued because owner affinity is deliberate.
+ Assert.IsFalse(InteractiveDesktopScheduler.RunnableParticipants(state).Contains((200, 200)));
+
+ var before = InteractiveDesktopScheduler.RunnableParticipants(state);
+ _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs + 1);
+ _scheduler.Normalize(state, _probe);
+
+ Assert.IsTrue(
+ InteractiveDesktopScheduler.RunnableParticipants(state).Except(before).Contains((200, 200)),
+ "an expired grace is a deadline nobody announces, so the waiter that wakes at it must find itself runnable");
+ }
+
+ [TestMethod]
+ public void ARegisteringObservationIsReportedAsNewlyRunnable()
+ {
+ // An observation is Running the moment it registers, so it does appear in the difference. That
+ // is correct rather than wasteful: the entry is a real change to who may act on the desktop.
+ // Nobody is woken for it, because PublishAndSignal skips the participant doing the publishing —
+ // which for a registration is always the observation itself.
+ var state = InteractiveDesktopState.CreateFresh();
+ var owner = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, owner, UiTurnMode.DesktopExclusive);
+
+ var before = InteractiveDesktopScheduler.RunnableParticipants(state);
+ var observer = Participant(300, "ui inspect");
+ _scheduler.BeginObserve(state, _probe, OwnerA, observer);
+
+ var newlyRunnable = InteractiveDesktopScheduler.RunnableParticipants(state).Except(before).ToList();
+ Assert.HasCount(1, newlyRunnable, "the observation is the only change");
+ Assert.AreEqual((300, 300), newlyRunnable[0]);
+ }
+
+ // ------------------------------------------------------------------------ liveness probe economy
+
+ [TestMethod]
+ public void AdmissionProbesEachParticipantOnceRatherThanOncePerCheck()
+ {
+ // Admission used to prune, then re-count live waiters, then compute a queue position — asking
+ // the OS about the same processes three times inside one transaction. With 64 waiters that is
+ // 192 process handles per admitted command.
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+ for (var i = 0; i < 20; i++)
+ {
+ _scheduler.BeginParticipating(
+ state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, $"owner{i}"),
+ Participant(1_000 + i), UiTurnMode.DesktopExclusive);
+ }
+
+ _probe.Reset();
+ var afterSetup = _probe.Calls;
+ _scheduler.Normalize(state, _probe);
+ var afterNormalize = _probe.Calls;
+ _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(9_999), UiTurnMode.DesktopExclusive);
+ var afterAdmission = _probe.Calls;
+
+ // 21 existing participants: one command entry plus 20 waiters, each asked about exactly once by
+ // the prune inside Normalize. Everything after that reads the pruned lists.
+ Assert.AreEqual(21, afterNormalize - afterSetup,
+ $"one normalization must probe each participant once; it probed {afterNormalize - afterSetup}");
+ Assert.AreEqual(21, afterAdmission - afterNormalize,
+ $"admission must probe each participant once; it probed {afterAdmission - afterNormalize}");
+ }
+
+ [TestMethod]
+ public void PromotionReusesThePrunedQueueRatherThanReprobingIt()
+ {
+ // Promotion is the path that re-walked the queue asking the OS about each waiter again. It runs
+ // inside the completion that frees the turn, so that is the transaction to measure.
+ var state = InteractiveDesktopState.CreateFresh();
+ var owner = Participant(100);
+ _scheduler.BeginParticipating(state, _probe, OwnerA, owner, UiTurnMode.DesktopExclusive);
+ for (var i = 0; i < 10; i++)
+ {
+ _scheduler.BeginParticipating(
+ state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, $"owner{i}"),
+ Participant(2_000 + i), UiTurnMode.DesktopExclusive);
+ }
+
+ _probe.Reset();
+ _scheduler.CompleteCommand(state, _probe, owner, OwnerA, renewGrace: false);
+
+ Assert.IsNotNull(state.Owner, "completing the only owner command must promote a waiting owner");
+ Assert.AreEqual(10, _probe.Calls,
+ $"promotion must reuse the list the prune just produced; it probed {_probe.Calls} times");
+ }
+
+ [TestMethod]
+ public void QueuePositionStillProbesWhenNothingHasNormalized()
+ {
+ // Cancellation teardown reads a position without normalizing first, so there the probe is the
+ // only thing that can tell a live queue from a stale one.
+ var state = InteractiveDesktopState.CreateFresh();
+ _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive);
+ var first = Participant(200);
+ var second = Participant(300);
+ _scheduler.BeginParticipating(state, _probe, OwnerB, first, UiTurnMode.DesktopExclusive);
+ var admission = _scheduler.BeginParticipating(
+ state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc"), second, UiTurnMode.DesktopExclusive);
+
+ var ticket = admission.Ticket!.Value;
+ Assert.AreEqual(2, InteractiveDesktopScheduler.QueuePositionOf(state, _probe, ticket));
+
+ // The waiter ahead dies without anyone normalizing: the probing overload must notice.
+ _probe.Alive.Remove((200, 200));
+ Assert.AreEqual(1, InteractiveDesktopScheduler.QueuePositionOf(state, _probe, ticket),
+ "a stale queue must not inflate the reported position");
+
+ // The non-probing overload is for callers that already normalized, and counts entries as-is.
+ Assert.AreEqual(2, InteractiveDesktopScheduler.QueuePositionOf(state, ticket));
+ }
+
+ private sealed class CountingLivenessProbe : ICoordinationLivenessProbe
+ {
+ public HashSet<(int Pid, long Start)> Alive { get; } = [];
+
+ public int Calls { get; private set; }
+
+ public void Reset() => Calls = 0;
+
+ public bool IsParticipantLive(int processId, long startTicksUtc)
+ {
+ Calls++;
+ return Alive.Contains((processId, startTicksUtc));
+ }
+ }
+
+ private sealed class TestClock : IMonotonicClock
+ {
+ private long _now = 1_000_000;
+
+ public long NowTicks64 => _now;
+
+ public DateTimeOffset UtcNow => DateTimeOffset.UnixEpoch.AddMilliseconds(_now);
+
+ public void Advance(long ms) => _now += ms;
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs
new file mode 100644
index 000000000..e014a1a5d
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs
@@ -0,0 +1,808 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Security.AccessControl;
+using System.Security.Principal;
+using System.Text.Json;
+using Microsoft.Extensions.Logging.Abstractions;
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// File-level coverage of the coordination store, participant leases, and lock-directory setup
+/// (issue #764). Everything here runs against a throwaway directory supplied through
+/// WINAPP_UI_LOCK_DIRECTORY, so a test never touches the developer's live coordination state.
+///
+[TestClass]
+[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY is process-wide.
+public class InteractiveDesktopStoreTests
+{
+ private string _lockDirectory = null!;
+ private string? _previousOverride;
+ private InteractiveDesktopPaths _paths = null!;
+ private ParticipantRegistry _participants = null!;
+ private InteractiveDesktopStateStore _store = null!;
+ private FakeProcessInspector _inspector = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-locks-{Guid.NewGuid():N}");
+ _previousOverride = Environment.GetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable);
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory);
+
+ _inspector = new FakeProcessInspector();
+ _paths = new InteractiveDesktopPaths(_inspector);
+ _participants = new ParticipantRegistry(_paths, _inspector, NullLogger.Instance);
+ _store = new InteractiveDesktopStateStore(
+ _paths, _participants, new FixedClock(), NullLogger.Instance);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousOverride);
+ try
+ {
+ if (Directory.Exists(_lockDirectory))
+ {
+ Directory.Delete(_lockDirectory, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // A leaked temp directory must never fail a test.
+ }
+ }
+
+ // ------------------------------------------------------------------------------- fresh vs corrupt
+
+ [TestMethod]
+ public void Read_MissingFile_StartsFresh()
+ {
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var result = _store.Read();
+
+ Assert.IsFalse(result.RecoveredFromCorruption, "a missing file is an ordinary first run");
+ Assert.IsNotNull(result.State);
+ Assert.IsNull(result.State!.Owner);
+ }
+
+ [TestMethod]
+ public void Read_EmptyFile_IsTreatedAsCorruptionNotFreshState()
+ {
+ // Atomic publication never produces an empty file, so one means a torn write or truncation.
+ _paths.EnsureDirectories();
+ File.WriteAllText(_paths.StatePath, string.Empty);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var result = _store.Read();
+
+ Assert.IsTrue(result.RecoveredFromCorruption,
+ "an existing empty state file must take the guarded recovery path");
+ Assert.IsTrue(
+ Directory.EnumerateFiles(_paths.LockDirectory, "state.corrupt-*.json").Any(),
+ "the unreadable file must be quarantined rather than silently discarded");
+ }
+
+ [TestMethod]
+ public void Read_WhitespaceFile_IsTreatedAsCorruption()
+ {
+ _paths.EnsureDirectories();
+ File.WriteAllText(_paths.StatePath, " \r\n ");
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ Assert.IsTrue(_store.Read().RecoveredFromCorruption);
+ }
+
+ [TestMethod]
+ public void Read_CorruptStateWithALiveParticipant_FailsClosed()
+ {
+ _paths.EnsureDirectories();
+ File.WriteAllText(_paths.StatePath, "{ this is not json");
+
+ // A live lease means some other winapp process is mid-workflow; rebuilding state under it would
+ // strand its ownership and let two processes drive the desktop.
+ using var lease = _participants.OpenLease(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var ex = Assert.ThrowsExactly(() => _store.Read());
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+ }
+
+ [TestMethod]
+ public void Read_EmptyStateWithALiveParticipant_FailsClosed()
+ {
+ _paths.EnsureDirectories();
+ File.WriteAllText(_paths.StatePath, string.Empty);
+
+ using var lease = _participants.OpenLease(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var ex = Assert.ThrowsExactly(() => _store.Read());
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+ }
+
+ [TestMethod]
+ public void Read_IncompleteObject_IsTreatedAsCorruption()
+ {
+ // An empty object is not a fresh state, it is a truncated one. Every property carried a
+ // defaulting initializer, so `{}` bound to exactly what CreateFresh() produces — version 1,
+ // ticket 1, no owner, no commands — and sailed through structural validation as if a real
+ // coordinator had written it. A file truncated to `{}` by a crashed writer, a disk-full
+ // rewrite or a stray editor therefore looked like "nobody is using the desktop".
+ _paths.EnsureDirectories();
+ File.WriteAllText(_paths.StatePath, "{}");
+
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var result = _store.Read();
+ Assert.IsTrue(
+ result.RecoveredFromCorruption,
+ "a document missing every field the writer always emits must be corruption, not a fresh state");
+ Assert.IsNotNull(result.State);
+ }
+
+ Assert.IsTrue(
+ Directory.EnumerateFiles(_paths.LockDirectory, "state.corrupt-*.json").Any(),
+ "the unusable document must be quarantined rather than silently believed");
+ }
+
+ [TestMethod]
+ public void Read_IncompleteObjectWithALiveParticipant_FailsClosed()
+ {
+ // The consequence that makes this critical rather than untidy. Believing `{}` mints a fresh
+ // owner while another process still holds its lease, so two workflows drive one desktop at
+ // once. With a live lease the read must refuse instead.
+ _paths.EnsureDirectories();
+ File.WriteAllText(_paths.StatePath, "{}");
+
+ using var lease = _participants.OpenLease(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var ex = Assert.ThrowsExactly(() => _store.Read());
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+
+ Assert.AreEqual("{}", File.ReadAllText(_paths.StatePath),
+ "failing closed must leave the bytes alone rather than replacing them with a fresh state");
+ }
+
+ [TestMethod]
+ public void Read_WaiterMissingARequiredField_IsTreatedAsCorruption()
+ {
+ // A partially written entry is worse than a missing one: an absent ownerKind would default to
+ // whichever enum value is zero, so a truncated waiter would be promoted later with an owner
+ // kind nobody ever wrote — deciding, among other things, whether it earns an idle grace.
+ _paths.EnsureDirectories();
+ const string truncatedWaiter = """
+ {"version":1,"turnId":0,"turnStartedTick64":0,"nextTicket":2,"idleExpiresTick64":0,
+ "ownerCommands":[],
+ "waiters":[{"ticket":1,"ownerKey":"abc","pid":4242,"processStartTicksUtc":99,"operation":"ui click","mode":"desktop-exclusive"}]}
+ """;
+ File.WriteAllText(_paths.StatePath, truncatedWaiter);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var result = _store.Read();
+
+ Assert.IsTrue(
+ result.RecoveredFromCorruption,
+ "a waiter missing ownerKind must not default into valid scheduling semantics");
+ }
+
+ [TestMethod]
+ public void Read_OwnerCommandMissingStatus_IsTreatedAsCorruption()
+ {
+ // Status decides whether a command is running or still blocked behind a barrier. Defaulting it
+ // silently would let a truncated entry claim either.
+ _paths.EnsureDirectories();
+ const string truncatedCommand = """
+ {"version":1,"turnId":1,"turnStartedTick64":5,"nextTicket":2,"idleExpiresTick64":0,
+ "owner":{"kind":"workflow","key":"abc"},
+ "ownerCommands":[{"ticket":1,"pid":4242,"processStartTicksUtc":99,"operation":"ui click","mode":"desktop-exclusive"}],
+ "waiters":[]}
+ """;
+ File.WriteAllText(_paths.StatePath, truncatedCommand);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var result = _store.Read();
+
+ Assert.IsTrue(
+ result.RecoveredFromCorruption,
+ "an owner command missing status must not default into a runnable entry");
+ }
+
+ [TestMethod]
+ public void Read_AFreshlyPublishedState_StillRoundTrips()
+ {
+ // The other half of the contract: requiring fields must not reject what this binary writes.
+ _paths.EnsureDirectories();
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var published = InteractiveDesktopState.CreateFresh();
+ published.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = "abc" };
+ published.TurnId = 3;
+ published.TurnStartedTick64 = 1_234;
+ published.OwnerCommands.Add(new OwnerCommandEntry
+ {
+ Ticket = published.AllocateTicket(),
+ Pid = 4242,
+ ProcessStartTicksUtc = 99,
+ Operation = "ui click",
+ Mode = UiTurnMode.DesktopExclusive,
+ Status = UiCommandStatus.Running,
+ });
+ published.Waiters.Add(new WaiterEntry
+ {
+ Ticket = published.AllocateTicket(),
+ OwnerKey = "def",
+ OwnerKind = UiOwnerKind.Anonymous,
+ Pid = 5252,
+ ProcessStartTicksUtc = 100,
+ Operation = "ui screenshot",
+ Mode = UiTurnMode.DesktopExclusive,
+ });
+ _store.Publish(published);
+
+ var result = _store.Read();
+
+ Assert.IsFalse(result.RecoveredFromCorruption, "this binary's own output must read back cleanly");
+ Assert.IsFalse(result.UnknownNewerVersion);
+ Assert.IsNotNull(result.State);
+ Assert.AreEqual(3, result.State!.TurnId);
+ Assert.AreEqual("abc", result.State.Owner!.Key);
+ Assert.HasCount(1, result.State.OwnerCommands);
+ Assert.HasCount(1, result.State.Waiters);
+ Assert.AreEqual(UiOwnerKind.Anonymous, result.State.Waiters[0].OwnerKind);
+ Assert.AreEqual(UiCommandStatus.Running, result.State.OwnerCommands[0].Status);
+ }
+
+ [TestMethod]
+ public void Read_UnknownNewerVersion_IsNeverResetOrDowngraded()
+ {
+ _paths.EnsureDirectories();
+ const string future = """{"version":99,"turnId":7,"nextTicket":3,"ownerCommands":[],"waiters":[]}""";
+ File.WriteAllText(_paths.StatePath, future);
+
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var result = _store.Read();
+ Assert.IsTrue(result.UnknownNewerVersion);
+ Assert.IsNull(result.State);
+ Assert.IsFalse(result.RecoveredFromCorruption, "a newer schema is not corruption");
+ }
+
+ Assert.AreEqual(future, File.ReadAllText(_paths.StatePath), "the newer document must be left alone");
+ }
+
+ [TestMethod]
+ public void Read_UnknownNewerVersionWithIncompatibleFieldShapes_IsNotTreatedAsCorruption()
+ {
+ // A newer schema may change field *shapes*, not merely add fields. Here `owner` is a string
+ // rather than an object, so strong deserialization throws — and the version check used to run
+ // only AFTER that, so a perfectly valid v99 document was classified as corruption, quarantined,
+ // and replaced with a v1 document. A published repro had `ui list-windows` silently downgrade
+ // the file. The version must therefore be read from the raw document first.
+ _paths.EnsureDirectories();
+ const string future = """{"version":99,"owner":"a-newer-shape","turnId":7,"nextTicket":3}""";
+ File.WriteAllText(_paths.StatePath, future);
+
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var result = _store.Read();
+ Assert.IsTrue(result.UnknownNewerVersion, "a newer schema must be reported as such, not as corruption");
+ Assert.IsNull(result.State);
+ Assert.IsFalse(result.RecoveredFromCorruption);
+ }
+
+ Assert.AreEqual(future, File.ReadAllText(_paths.StatePath), "the newer document must be left byte-for-byte");
+ Assert.AreEqual(
+ 0,
+ Directory.GetFiles(_paths.LockDirectory, "state.corrupt-*.json").Length,
+ "a newer document must never be quarantined");
+ }
+
+ [TestMethod]
+ public void Read_MalformedJson_IsStillCorruptionNotAVersionDivert()
+ {
+ // The raw version probe must not swallow genuine corruption: a document that is not JSON at all
+ // has no readable version and has to keep taking the guarded recovery path.
+ WriteRawState("{this is not json");
+
+ AssertRecovered();
+ }
+
+ [TestMethod]
+ public void Read_VersionOfTheWrongJsonType_FallsThroughToCorruptionRecovery()
+ {
+ // `version` present but not a number is malformed rather than "newer", so it must not be
+ // mistaken for a future schema and left in place forever.
+ WriteRawState("""{"version":"ninety-nine","turnId":1,"nextTicket":2}""");
+
+ AssertRecovered();
+ }
+
+ // ------------------------------------------------------------------- structural validation
+
+ [TestMethod]
+ public void Read_DuplicateTicketAcrossOwnerCommandsAndWaiters_IsCorrupt()
+ {
+ // Ticket 5 appearing in both lists would make two commands share one barrier position.
+ WriteRawState("""
+ {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"workflow","key":"a"},
+ "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":"DesktopExclusive","status":"running"}],
+ "waiters":[{"ticket":5,"ownerKey":"b","ownerKind":"workflow","pid":11,"processStartTicksUtc":2,"operation":"ui click","mode":"DesktopExclusive"}]}
+ """);
+
+ AssertRecovered();
+ }
+
+ [TestMethod]
+ public void Read_NextTicketNotAheadOfEveryPersistedTicket_IsCorrupt()
+ {
+ // nextTicket 5 would re-issue ticket 5 and collide with the running command.
+ WriteRawState("""
+ {"version":1,"turnId":1,"nextTicket":5,"owner":{"kind":"workflow","key":"a"},
+ "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":"DesktopExclusive","status":"running"}],
+ "waiters":[]}
+ """);
+
+ AssertRecovered();
+ }
+
+ [TestMethod]
+ public void Read_OwnerCommandsWithoutAnOwner_IsCorrupt()
+ {
+ WriteRawState("""
+ {"version":1,"turnId":1,"nextTicket":9,
+ "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":"DesktopExclusive","status":"running"}],
+ "waiters":[]}
+ """);
+
+ AssertRecovered();
+ }
+
+ [TestMethod]
+ public void Read_ObserveEntryCarryingATicket_IsCorrupt()
+ {
+ // Observations never serialize as barriers, so a ticket on one is meaningless and would be
+ // compared against real barrier tickets.
+ WriteRawState("""
+ {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"workflow","key":"a"},
+ "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui inspect","mode":"Observe","status":"running"}],
+ "waiters":[]}
+ """);
+
+ AssertRecovered();
+ }
+
+ [TestMethod]
+ public void Read_OutOfRangeEnumValue_IsCorrupt()
+ {
+ WriteRawState("""
+ {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"workflow","key":"a"},
+ "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":42,"status":"running"}],
+ "waiters":[]}
+ """);
+
+ AssertRecovered();
+ }
+
+ // ------------------------------------------------------------------- publication round-trip
+
+ [TestMethod]
+ public void Publish_RoundTripsStateAndPreservesUnknownFieldsFromANewerWriter()
+ {
+ // The fixture carries every field the v1 writer emits, because a document missing one of those
+ // is now corruption rather than a partially populated state. Confirmed against a real published
+ // file: version, turnId, turnStartedTick64, nextTicket, idleExpiresTick64, ownerCommands and
+ // waiters are always written.
+ _paths.EnsureDirectories();
+ WriteRawState("""
+ {"version":1,"turnId":3,"turnStartedTick64":100,"nextTicket":9,"idleExpiresTick64":0,
+ "owner":{"kind":"workflow","key":"a","futureOwnerField":"keep-me"},
+ "ownerCommands":[],"waiters":[],"futureRootField":{"nested":true}}
+ """);
+
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ var state = _store.Read().State!;
+ state.TurnId = 4;
+ _store.Publish(state);
+ }
+
+ using var document = JsonDocument.Parse(File.ReadAllText(_paths.StatePath));
+ Assert.AreEqual(4, document.RootElement.GetProperty("turnId").GetInt32());
+ Assert.IsTrue(document.RootElement.TryGetProperty("futureRootField", out _),
+ "unknown root fields from a newer writer must survive a rewrite");
+ Assert.IsTrue(document.RootElement.GetProperty("owner").TryGetProperty("futureOwnerField", out _),
+ "unknown owner fields from a newer writer must survive a rewrite");
+ }
+
+ [TestMethod]
+ public void Publish_LeavesNoTemporaryFilesBehind()
+ {
+ using (var stateLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ _store.Publish(InteractiveDesktopState.CreateFresh());
+ }
+
+ Assert.AreEqual(0, Directory.EnumerateFiles(_paths.LockDirectory, "*.tmp").Count());
+ }
+
+ [TestMethod]
+ public void StateRemainsReadableWhileTheActiveLockIsHeld()
+ {
+ // active.lock guards the desktop, not the metadata: a queued command must still be able to read
+ // and update state while another process is mid-gesture.
+ _paths.EnsureDirectories();
+
+ // A process holding active.lock has necessarily registered first, so state already exists.
+ // (Missing state while active.lock is held is a different case entirely — an external deletion —
+ // and is deliberately fail-closed; see MissingStateWhileTheActiveLockIsHeldFailsClosed.)
+ using (var seedLock = _store.AcquireStateLock(CancellationToken.None))
+ {
+ _store.Publish(InteractiveDesktopState.CreateFresh());
+ }
+
+ using var activeLock = new FileStream(
+ _paths.ActiveLockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var state = _store.Read().State!;
+ state.TurnId = 11;
+ _store.Publish(state);
+
+ Assert.IsFalse(_store.IsActiveLockFree());
+ }
+
+ // ------------------------------------------------------- missing state must not mint a new owner
+
+ [TestMethod]
+ public void MissingStateWithNoLivenessEvidenceIsATrueFirstUse()
+ {
+ _paths.EnsureDirectories();
+ Assert.IsFalse(File.Exists(_paths.StatePath), "the test starts with no state file at all");
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var read = _store.Read();
+
+ Assert.IsNotNull(read.State, "the ordinary first command on a desktop starts from a fresh document");
+ Assert.IsNull(read.State!.Owner);
+ Assert.IsFalse(read.RecoveredFromCorruption, "a first use is not a recovery");
+ }
+
+ [TestMethod]
+ public void MissingStateWhileAParticipantIsLiveFailsClosed()
+ {
+ // An external deletion — AV, manual cleanup, a stray rmdir — while a recording or queued waiter
+ // is still live. Rebuilding here would mint a second owner for the same desktop.
+ _paths.EnsureDirectories();
+ using var lease = _participants.OpenLease(
+ _inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc);
+
+ Assert.IsFalse(File.Exists(_paths.StatePath));
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var ex = Assert.ThrowsExactly(() => _store.Read());
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+ }
+
+ [TestMethod]
+ public void MissingStateWhileTheActiveLockIsHeldFailsClosed()
+ {
+ _paths.EnsureDirectories();
+ using var activeLock = new FileStream(
+ _paths.ActiveLockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
+
+ Assert.IsFalse(File.Exists(_paths.StatePath));
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var ex = Assert.ThrowsExactly(() => _store.Read());
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+ }
+
+ // ------------------------------------------------------------------ prior-boot deadline handling
+
+ [TestMethod]
+ public void PublishSurvivesAnUnrepresentableIdleDeadline()
+ {
+ // Environment.TickCount64 resets on reboot, so a state file written after long uptime can carry
+ // a deadline far beyond the current uptime. Converting that delta to a UTC diagnostic overflows
+ // DateTime; a diagnostic string must never be able to fail a publish.
+ _paths.EnsureDirectories();
+ var state = InteractiveDesktopState.CreateFresh();
+ state.IdleExpiresTick64 = long.MaxValue;
+
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ _store.Publish(state);
+
+ Assert.IsNull(state.DiagnosticIdleExpiresUtc,
+ "an unrepresentable deadline is omitted from diagnostics rather than throwing");
+ Assert.IsTrue(File.Exists(_paths.StatePath), "the publish itself must still succeed");
+ }
+
+ // --------------------------------------------------------------------------- participant leases
+ [TestMethod]
+ public void Lease_IsHeldWhileOpenAndDeletedOnClose()
+ {
+ var leasePath = _paths.LeasePath(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc);
+
+ var lease = _participants.OpenLease(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc);
+ Assert.IsTrue(File.Exists(leasePath));
+ Assert.IsTrue(_participants.IsParticipantLive(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc));
+ Assert.IsTrue(_participants.AnyLiveParticipant());
+
+ lease.Dispose();
+
+ // DeleteOnClose means the OS removes the file, which is exactly what makes heartbeats unnecessary.
+ Assert.IsFalse(File.Exists(leasePath));
+ Assert.IsFalse(_participants.AnyLiveParticipant());
+ }
+
+ [TestMethod]
+ public void Lease_OrphanedFileFromAPowerLossIsNotLiveAndIsCleanedUp()
+ {
+ _paths.EnsureDirectories();
+ var orphan = _paths.LeasePath(999_999, 12_345);
+ File.WriteAllText(orphan, string.Empty);
+
+ Assert.IsFalse(_participants.IsParticipantLive(999_999, 12_345),
+ "an openable lease proves its holder is gone");
+ Assert.IsFalse(File.Exists(orphan), "the stale lease file must be removed while probing");
+ }
+
+ [TestMethod]
+ public void Lease_FileNameRoundTripsThroughTheProcessIdentity()
+ {
+ var path = _paths.LeasePath(4242, 987_654_321);
+ Assert.IsTrue(_paths.TryParseLeaseFileName(Path.GetFileName(path), out var pid, out var startTicks));
+ Assert.AreEqual(4242, pid);
+ Assert.AreEqual(987_654_321, startTicks);
+ }
+
+ // ------------------------------------------------------------------------- lock directory setup
+
+ [TestMethod]
+ public void EnsureDirectories_RepairsAnExistingDirectoryWithInheritedPermissions()
+ {
+ // A WINAPP_UI_LOCK_DIRECTORY pointed at a shared location can already exist with inherited
+ // rules that let another user tamper with coordination state or hold a lease.
+ Directory.CreateDirectory(_lockDirectory);
+ var before = new DirectoryInfo(_lockDirectory).GetAccessControl();
+ Assert.IsFalse(before.AreAccessRulesProtected, "precondition: the directory starts with inherited rules");
+
+ _paths.EnsureDirectories();
+
+ var after = new DirectoryInfo(_lockDirectory).GetAccessControl();
+ Assert.IsTrue(after.AreAccessRulesProtected,
+ "an existing coordination directory must not be left with inherited permissions");
+ }
+
+ [TestMethod]
+ public void EnsureDirectories_IsSafeToCallRepeatedlyAndConcurrently()
+ {
+ // Every state-lock acquisition and lease open calls this, and several winapp processes can race.
+ Parallel.For(0, 16, _ => _paths.EnsureDirectories());
+
+ Assert.IsTrue(Directory.Exists(_paths.LockDirectory));
+ Assert.IsTrue(Directory.Exists(_paths.ParticipantsDirectory));
+ }
+
+ [TestMethod]
+ public void Paths_RejectARelativeOverrideDirectory()
+ {
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, "relative\\locks");
+
+ // A relative path resolves against the caller's working directory, so two winapp processes
+ // started in different folders would silently coordinate against different files.
+ var ex = Assert.ThrowsExactly(() => new InteractiveDesktopPaths(_inspector));
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+ }
+
+ [TestMethod]
+ public void Paths_RejectANetworkOverrideDirectory()
+ {
+ Environment.SetEnvironmentVariable(
+ InteractiveDesktopPaths.LockDirectoryOverrideVariable, @"\\server\share\locks");
+
+ // SMB byte-range locking is advisory, so exclusive-share semantics would silently not exclude.
+ var ex = Assert.ThrowsExactly(() => new InteractiveDesktopPaths(_inspector));
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code);
+ }
+
+ [TestMethod]
+ public void Paths_ScopeEveryArtifactToTheWindowsSession()
+ {
+ // Two signed-in sessions have independent foreground/focus/input, so they must not queue
+ // behind each other.
+ var otherSession = new InteractiveDesktopPaths(new FakeProcessInspector { CurrentSessionId = 7 });
+
+ Assert.AreNotEqual(_paths.StatePath, otherSession.StatePath);
+ Assert.AreNotEqual(_paths.ActiveLockPath, otherSession.ActiveLockPath);
+ Assert.AreNotEqual(_paths.StateLockPath, otherSession.StateLockPath);
+ }
+
+ private void WriteRawState(string json)
+ {
+ _paths.EnsureDirectories();
+ File.WriteAllText(_paths.StatePath, json);
+ }
+
+ private void AssertRecovered()
+ {
+ using var stateLock = _store.AcquireStateLock(CancellationToken.None);
+ var result = _store.Read();
+ Assert.IsTrue(result.RecoveredFromCorruption,
+ "structurally invalid scheduling state must be quarantined, not used");
+ Assert.IsNotNull(result.State);
+ Assert.IsNull(result.State!.Owner);
+ }
+
+ private sealed class FixedClock : IMonotonicClock
+ {
+ public long NowTicks64 => 1_000_000;
+
+ public DateTimeOffset UtcNow => new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ }
+
+ ///
+ /// Reports this test process's real identity — the lease protocol needs a genuinely live process —
+ /// while letting a test vary the Windows session id.
+ ///
+ private sealed class FakeProcessInspector : IProcessInspector
+ {
+ private readonly ProcessInspector _real = new();
+
+ public int CurrentSessionId { get; init; } = 1;
+
+ public int CurrentProcessId => _real.CurrentProcessId;
+
+ public long CurrentProcessStartTicksUtc => _real.CurrentProcessStartTicksUtc;
+
+ public bool? IsProcessAlive(int processId, long startTicksUtc) => _real.IsProcessAlive(processId, startTicksUtc);
+ }
+
+ // ------------------------------------------------------------------------ lock retryability
+
+ [TestMethod]
+ public void OnlySharingAndLockViolationsAreTreatedAsContention()
+ {
+ // Win32 codes arrive in the low word of HResult as 0x8007xxxx.
+ Assert.IsTrue(CoordinationLockIo.IsContention(new IOException("busy", unchecked((int)0x80070020))),
+ "ERROR_SHARING_VIOLATION means another process holds the file");
+ Assert.IsTrue(CoordinationLockIo.IsContention(new IOException("locked", unchecked((int)0x80070021))),
+ "ERROR_LOCK_VIOLATION means a byte-range lock is held");
+ }
+
+ [TestMethod]
+ public void OtherIoFailuresAreNotContentionAndMustNotBeRetried()
+ {
+ // Retrying these forever would be indistinguishable from waiting on a real lock, so the command
+ // would hang instead of reporting that coordination is unavailable.
+ Assert.IsFalse(CoordinationLockIo.IsContention(new IOException("gone", unchecked((int)0x80070003))),
+ "ERROR_PATH_NOT_FOUND will never clear by waiting");
+ Assert.IsFalse(CoordinationLockIo.IsContention(new IOException("device", unchecked((int)0x8007001F))),
+ "ERROR_GEN_FAILURE is a real device failure");
+ Assert.IsFalse(CoordinationLockIo.IsContention(new IOException("handles", unchecked((int)0x80070004))),
+ "ERROR_TOO_MANY_OPEN_FILES is a process-level failure");
+ Assert.IsFalse(CoordinationLockIo.IsContention(new FileNotFoundException()),
+ "a missing file is not contention");
+ }
+
+ [TestMethod]
+ public void ANonContentionStateLockFailureReportsCoordinationUnavailable()
+ {
+ // A directory sitting where the lock file belongs makes FileStream fail with a non-sharing
+ // error, which must fail closed rather than spin forever.
+ _paths.EnsureDirectories();
+ var occupied = Path.Combine(_paths.LockDirectory, "occupied.lock");
+ Directory.CreateDirectory(occupied);
+
+ var store = new InteractiveDesktopStateStore(
+ new RedirectedStateLockPaths(_paths, occupied), _participants, new TickCountClock(),
+ NullLogger.Instance);
+
+ var ex = Assert.ThrowsExactly(
+ () => store.AcquireStateLock(CancellationToken.None).Dispose());
+ Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code,
+ "a real I/O failure must fail closed instead of retrying forever");
+ }
+
+ /// Redirects only state.lock, so a test can point it at an unusable path.
+ private sealed class RedirectedStateLockPaths(IInteractiveDesktopPaths inner, string stateLockPath)
+ : IInteractiveDesktopPaths
+ {
+ public string LockDirectory => inner.LockDirectory;
+
+ public string ParticipantsDirectory => inner.ParticipantsDirectory;
+
+ public string StatePath => inner.StatePath;
+
+ public string StateLockPath => stateLockPath;
+
+ public string ActiveLockPath => inner.ActiveLockPath;
+
+ public string LeaseSearchPattern => inner.LeaseSearchPattern;
+
+ public string LeasePath(int processId, long startTicksUtc) => inner.LeasePath(processId, startTicksUtc);
+
+ public bool TryParseLeaseFileName(string fileName, out int processId, out long startTicksUtc)
+ => inner.TryParseLeaseFileName(fileName, out processId, out startTicksUtc);
+
+ public void EnsureDirectories() => inner.EnsureDirectories();
+ }
+
+ // ------------------------------------------------------------------------ directory ownership
+
+ [TestMethod]
+ public void ADirectoryOwnedByAnotherUserIsRejectedEvenWithACurrentUserOnlyDacl()
+ {
+ // The owner of an object implicitly holds WRITE_DAC, so a foreign owner can rewrite even a
+ // protected, current-user-only DACL at any moment. Checking the DACL alone is not enough.
+ var currentUser = WindowsIdentity.GetCurrent().User!;
+ var stranger = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
+
+ var security = new DirectorySecurity();
+ security.SetOwner(stranger);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.AddAccessRule(new FileSystemAccessRule(
+ currentUser, FileSystemRights.FullControl,
+ InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
+ PropagationFlags.None, AccessControlType.Allow));
+
+ Assert.IsFalse(InteractiveDesktopPaths.IsCurrentUserOnly(security, currentUser),
+ "a foreign owner retains WRITE_DAC and can re-permission the directory behind our back");
+ }
+
+ [TestMethod]
+ public void ADirectoryOwnedByTheCurrentUserWithAProtectedSelfOnlyDaclIsAccepted()
+ {
+ var currentUser = WindowsIdentity.GetCurrent().User!;
+
+ var security = new DirectorySecurity();
+ security.SetOwner(currentUser);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.AddAccessRule(new FileSystemAccessRule(
+ currentUser, FileSystemRights.FullControl,
+ InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
+ PropagationFlags.None, AccessControlType.Allow));
+
+ Assert.IsTrue(InteractiveDesktopPaths.IsCurrentUserOnly(security, currentUser));
+ }
+
+ [TestMethod]
+ public void ADirectoryGrantingAnotherIdentityIsRejectedEvenWhenOwnedByTheCurrentUser()
+ {
+ var currentUser = WindowsIdentity.GetCurrent().User!;
+ var everyone = new SecurityIdentifier(WellKnownSidType.WorldSid, null);
+
+ var security = new DirectorySecurity();
+ security.SetOwner(currentUser);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.AddAccessRule(new FileSystemAccessRule(
+ everyone, FileSystemRights.FullControl,
+ InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
+ PropagationFlags.None, AccessControlType.Allow));
+
+ Assert.IsFalse(InteractiveDesktopPaths.IsCurrentUserOnly(security, currentUser),
+ "a world-writable coordination directory must never be accepted");
+ }
+
+ [TestMethod]
+ public void ADirectoryWithInheritedRulesIsRejected()
+ {
+ var currentUser = WindowsIdentity.GetCurrent().User!;
+
+ var security = new DirectorySecurity();
+ security.SetOwner(currentUser);
+ security.SetAccessRuleProtection(isProtected: false, preserveInheritance: true);
+
+ Assert.IsFalse(InteractiveDesktopPaths.IsCurrentUserOnly(security, currentUser),
+ "inherited rules can grant whatever the parent grants, including other users");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.Helpers.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.Helpers.cs
index 2446b30a5..65083ebe5 100644
--- a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.Helpers.cs
+++ b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.Helpers.cs
@@ -14,6 +14,18 @@ public partial class RealRecordingTests
{
private const int ReadyTimeoutMs = 10_000;
+ ///
+ /// Whether the fixture window (or its top-level root) currently owns the foreground.
+ ///
+ ///
+ /// Uses the shipped guard rather than a private P/Invoke so the test asks the exact question the
+ /// engine asks before it agrees to capture the screen. A session that refuses activation — a
+ /// locked desktop, or an agent host running the suite in the background — cannot exercise the
+ /// screen path at all, and the caller reports that rather than failing on it.
+ ///
+ private static bool ForegroundBelongsToFixture(UiaTestFixture fixture)
+ => ForegroundGuard.ForegroundBelongsTo(fixture.Hwnd);
+
/// Real UI Automation engine, resolved the same way the CLI resolves it.
private static TestAutomation NewAutomation()
=> new(new ServiceCollection()
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs
index babc60966..47f53366e 100644
--- a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs
+++ b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs
@@ -6,6 +6,8 @@
using Windows.Win32.Foundation;
using WinApp.Cli.Services;
+using WinApp.Cli.Services.InteractiveDesktop;
+
namespace WinApp.Cli.Tests;
///
@@ -189,6 +191,18 @@ public async Task RecordAsync_CaptureScreen_UsesConsentedScreenPath()
var uiTarget = SessionFor(fx);
await ResolveAsync(svc, uiTarget, "btnInvoke");
+ // A screen-DC recording captures whatever is genuinely in front, so the engine now refuses to
+ // start one unless the target actually reached the foreground. That is a real precondition of
+ // this capture mode, not test scaffolding — satisfy it the same way a user would.
+ DesktopTestHelpers.ForceForeground(fx.Hwnd);
+ if (!ForegroundBelongsToFixture(fx))
+ {
+ Assert.Inconclusive(
+ "The fixture window could not be brought to the foreground on this session, so the "
+ + "consented screen path cannot be exercised. Foreground refusal itself is covered by "
+ + "CaptureForegroundSafetyTests, which drives the check directly.");
+ }
+
var output = Path.Combine(AppContext.BaseDirectory, "coverage-scratch", Guid.NewGuid().ToString("N"), "screen.mp4");
Directory.CreateDirectory(Path.GetDirectoryName(output)!);
var screenCalls = 0;
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs
new file mode 100644
index 000000000..d7b6b238a
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Commands;
+using WinApp.Cli.Helpers;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Command-level mapping of a failed screen-capture foreground verification (issue #764 re-review H6).
+///
+///
+/// --capture-screen BitBlts the live screen rather than a specific window, so it records
+/// whatever is actually in front. SetForegroundWindow is only a request and Windows refuses it
+/// under focus-stealing prevention, a UAC prompt, a locked session, or when another app activates
+/// itself in the same instant. A published repro pointed at a green target while a magenta decoy held
+/// the foreground: the command exited 0 and the PNG's centre pixel was magenta. Silently handing back
+/// an image of the wrong app is worse than failing, because the caller cannot tell.
+///
+/// The verification itself lives in UiAutomationService and is covered in
+/// ; these tests pin the contract callers see — the existing
+/// foreground_not_target code rather than internal_error, and no artifact written.
+///
+///
+public partial class UiCommandTests
+{
+ private static ForegroundLostException CaptureRefusal()
+ => new("Target window is not in the foreground — refusing to capture the screen.");
+
+ [TestMethod]
+ public async Task Screenshot_CaptureScreenForegroundRefused_ReportsForegroundNotTarget()
+ {
+ _fakeUia.ScreenshotThrow = CaptureRefusal();
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "decoy.png");
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--capture-screen", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode(UiJsonError.CodeForegroundNotTarget);
+ Assert.IsFalse(File.Exists(outputPath),
+ "no image may be published when the capture would have recorded the wrong window");
+ }
+
+ [TestMethod]
+ public async Task Record_CaptureScreenForegroundRefused_ReportsForegroundNotTarget()
+ {
+ _fakeRecording.RecordException = CaptureRefusal();
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "decoy.mp4");
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--capture-screen", "--duration-sec", "1", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode(UiJsonError.CodeForegroundNotTarget);
+ Assert.IsFalse(File.Exists(outputPath), "no MP4 may be published for a refused capture");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_MultiWindowCaptureScreenForegroundRefused_ReportsForegroundNotTarget()
+ {
+ // The multi-window path captures each window in a loop whose catch-all records per-window
+ // failures and then reports "No windows could be captured" as internal_error. A refused
+ // activation is a property of the desktop, not of one window, so it must escape that loop —
+ // otherwise --capture-screen on any app with an owned dialog still buries the real cause.
+ _fakeWindowFinder.OwnedWindowsResult = [((nint)99, 4321, "Owned Dialog")];
+ _fakeUia.ScreenshotThrow = CaptureRefusal();
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "decoy-multi.png");
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--capture-screen", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode(UiJsonError.CodeForegroundNotTarget);
+ Assert.IsFalse(File.Exists(outputPath));
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs
new file mode 100644
index 000000000..35e8f50da
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs
@@ -0,0 +1,631 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Commands;
+using WinApp.Cli.Helpers;
+using WinApp.Cli.Models;
+using WinApp.Cli.Services;
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Command-level coverage of cooperative desktop turns (issue #764): coordination is entered only
+/// after local validation, each command declares the right mode, desktop-sensitive work happens inside
+/// a section, and no command acts on a target it resolved before waiting.
+///
+public partial class UiCommandTests
+{
+ // ---------------------------------------------- validation must precede coordination (spec §10)
+
+ [TestMethod]
+ public async Task Click_MissingApp_NeverEntersCoordination()
+ {
+ // A malformed command must not open a participant lease, take an arrival ticket, or join an
+ // indefinite queue behind another workflow.
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["Button", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(0, _fakeDesktopLock.Runs.Count, "preflight must reject before coordination");
+ }
+
+ [TestMethod]
+ public async Task Click_MissingSelector_NeverEntersCoordination()
+ {
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(0, _fakeDesktopLock.Runs.Count);
+ }
+
+ [TestMethod]
+ public async Task SendKeys_SystemReservedCombo_IsRefusedBeforeCoordination()
+ {
+ // win+l can never be driven from automation, so it must never wait for the desktop first.
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["win+l", "-a", "TestApp", "--via", "send-input", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(0, _fakeDesktopLock.Runs.Count);
+ }
+
+ [TestMethod]
+ public async Task Record_InvalidDuration_NeverEntersCoordination()
+ {
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "-1", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(0, _fakeDesktopLock.Runs.Count);
+ }
+
+ [TestMethod]
+ public async Task Touch_InvalidGesture_NeverEntersCoordination()
+ {
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["Button", "-a", "TestApp", "--gesture", "wiggle", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(0, _fakeDesktopLock.Runs.Count);
+ }
+
+ // ------------------------------------------------------------------- mode classification (§6.1)
+
+ [TestMethod]
+ public async Task Inspect_IsAnObservation()
+ {
+ var command = GetRequiredService();
+ await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(1, _fakeDesktopLock.Runs.Count);
+ Assert.AreEqual(UiTurnMode.Observe, _fakeDesktopLock.Runs[0].Mode);
+ Assert.AreEqual("ui inspect", _fakeDesktopLock.Runs[0].Operation);
+ }
+
+ [TestMethod]
+ public async Task SetValue_TakesASharedTurnBecauseItChangesWhatTheAppShows()
+ {
+ // Background-safe: it drives ValuePattern and never takes active.lock, so it stays usable on a
+ // locked or headless session. But it does mutate the app, so it must wait behind a foreign
+ // workflow rather than editing a control underneath somebody else's click.
+ _fakeUia.FindSingleResult = new UiElement { Id = "box", Selector = "box", Name = "Box" };
+ var command = GetRequiredService();
+ await ParseAndInvokeWithCaptureAsync(command, ["box", "hello", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode);
+ Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters,
+ "a background-safe mutation must never take active.lock");
+ }
+
+ [TestMethod]
+ public async Task ScrollIntoView_TakesASharedTurnWithoutTakingTheDesktop()
+ {
+ _fakeUia.FindSingleResult = new UiElement { Id = "item", Selector = "item", Name = "Item" };
+ var command = GetRequiredService();
+ await ParseAndInvokeWithCaptureAsync(command, ["item", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode);
+ Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters);
+ }
+
+ [TestMethod]
+ public async Task Record_IsTurnSharedSoSameOwnerInputCanInterleave()
+ {
+ var command = GetRequiredService();
+ await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "--output", Path.Combine(_tempDirectory.FullName, "r.mp4"), "--json"]);
+
+ Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode);
+ }
+
+ [TestMethod]
+ public async Task Invoke_IsDesktopExclusive()
+ {
+ _fakeUia.FindSingleResult = new UiElement { Id = "btn", Selector = "btn", Name = "Button" };
+ var command = GetRequiredService();
+ await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode);
+ }
+
+ [TestMethod]
+ public async Task Scroll_ClassifiesByTransport()
+ {
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "list", Selector = "list", Name = "List", X = 10, Y = 10, Width = 100, Height = 100,
+ };
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+
+ // --direction uses the UIA ScrollPattern, which moves the container without touching the
+ // desktop: a shared turn, and no active.lock.
+ var command = GetRequiredService();
+ await ParseAndInvokeWithCaptureAsync(command, ["list", "-a", "TestApp", "--direction", "down", "--json"]);
+ Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode);
+ Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters,
+ "ScrollPattern must not take active.lock");
+
+ // --to is the same transport, so it classifies the same way.
+ _fakeDesktopLock.Runs.Clear();
+ await ParseAndInvokeWithCaptureAsync(command, ["list", "-a", "TestApp", "--to", "bottom", "--json"]);
+ Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode);
+ Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters);
+
+ // --wheel injects OS-wide mouse input at the cursor.
+ _fakeDesktopLock.Runs.Clear();
+ await ParseAndInvokeWithCaptureAsync(command, ["list", "-a", "TestApp", "--wheel", "3", "--json"]);
+ Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters,
+ "wheel input must run inside a desktop section");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_IsAlwaysDesktopExclusive()
+ {
+ _fakeUia.ScreenshotResult = (new byte[4 * 4 * 4], 4, 4);
+ var output = Path.Combine(_tempDirectory.FullName, "shot.png");
+ var command = GetRequiredService();
+
+ await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--output", output, "--json"]);
+ Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode);
+
+ _fakeDesktopLock.Runs.Clear();
+ await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--focus", "--output", output, "--json"]);
+ Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode);
+ }
+
+ // ------------------------------------------------- desktop-section placement and revalidation
+
+ [TestMethod]
+ public async Task Click_ForegroundsAndInjectsInsideADesktopSection()
+ {
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "btn", Selector = "btn", Name = "Button",
+ X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242,
+ };
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters,
+ "the foreground request and the click must run inside one desktop section");
+ Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections,
+ "the section must be released before the result is formatted");
+ CollectionAssert.Contains(_fakeDesktopForeground.ForegroundRequests, 4242L);
+ }
+
+ [TestMethod]
+ public async Task Click_RefreshesTheTargetWindowFromTheReResolvedElement()
+ {
+ // The window a queued command acts on must come from the read taken inside the section, not
+ // from the advisory read taken before the wait (spec §10.5).
+ var stale = new UiElement
+ {
+ Id = "btn", Selector = "btn", Name = "Button",
+ X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 1111,
+ };
+ var current = new UiElement
+ {
+ Id = "btn", Selector = "btn", Name = "Button",
+ X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 2222,
+ };
+ // A per-selector read sequence: the advisory read taken before the section sees the old window,
+ // every read inside the section sees the current one.
+ _fakeUia.MovingResults["btn"] = new Queue([stale, current, current, current, current]);
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ CollectionAssert.Contains(_fakeDesktopForeground.ForegroundRequests, 2222L);
+ CollectionAssert.DoesNotContain(_fakeDesktopForeground.ForegroundRequests, 1111L,
+ "the pre-wait window handle must never be foregrounded");
+ }
+
+ [TestMethod]
+ public async Task SendKeys_RefusesWhenTheTargetWindowClosedWhileQueued()
+ {
+ // Keystrokes are irreversible, so send-keys must revalidate its window after the queue wait just
+ // as click and invoke do — otherwise it types into whatever now owns a recycled handle.
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "txt", Selector = "txt", Name = "Value",
+ X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242,
+ };
+ _fakeSystemQuery.ProcessIdForWindowResult = 0;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["hello", "-a", "TestApp", "--target", "txt", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(0, _fakeDesktopForeground.ForegroundRequests.Count,
+ "a closed target must be refused before anything touches the desktop");
+ }
+
+ [TestMethod]
+ public async Task SendKeys_RefusesWhenTheWindowHandleWasRecycledByAnotherProcess()
+ {
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "txt", Selector = "txt", Name = "Value",
+ X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242,
+ };
+ // The handle now belongs to a different process: Windows reused it while this command queued.
+ _fakeSystemQuery.ProcessIdForWindowResult = 9999;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["hello", "-a", "TestApp", "--target", "txt", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(0, _fakeDesktopForeground.ForegroundRequests.Count,
+ "a recycled handle must be refused before anything touches the desktop");
+ }
+
+ [TestMethod]
+ public async Task SendKeys_WithoutATargetStillValidatesTheSessionWindow()
+ {
+ // With no --target the session HWND was captured before the queue wait, so it needs the same
+ // check: nothing re-resolves it on the way in.
+ _fakeTargetResolver.TargetResult = new UiTarget
+ {
+ ProcessId = 1234,
+ ProcessName = "TestApp",
+ WindowTitle = "Test Window",
+ WindowHandle = 4242,
+ };
+ _fakeSystemQuery.ProcessIdForWindowResult = 0;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["hello", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(0, _fakeDesktopForeground.ForegroundRequests.Count,
+ "a stale session window must be refused before anything touches the desktop");
+ }
+
+ [TestMethod]
+ public async Task Click_RefusesWhenTheTargetWindowClosedWhileQueued()
+ {
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "btn", Selector = "btn", Name = "Button",
+ X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242,
+ };
+ // 0 means "no such window": the target closed while this command waited for the desktop.
+ _fakeSystemQuery.ProcessIdForWindowResult = 0;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode("stale_element");
+ Assert.AreEqual(0, _fakeMouse.ClickCalls.Count, "no input may be injected at a dead target");
+ }
+
+ [TestMethod]
+ public async Task Click_RefusesWhenTheWindowHandleWasRecycledByAnotherProcess()
+ {
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "btn", Selector = "btn", Name = "Button",
+ X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242,
+ };
+ // The session resolves PID 1234; a different PID means Windows reused the handle.
+ _fakeSystemQuery.ProcessIdForWindowResult = 9999;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode("stale_element");
+ Assert.AreEqual(0, _fakeMouse.ClickCalls.Count);
+ }
+
+ [TestMethod]
+ public async Task Invoke_ReResolvesTheElementInsideTheDesktopSection()
+ {
+ var stale = new UiElement { Id = "btn", Selector = "btn", Name = "Stale", WindowHandle = 4242 };
+ var current = new UiElement { Id = "btn", Selector = "btn", Name = "Current", WindowHandle = 4242 };
+ _fakeUia.MovingResults["btn"] = new Queue([stale, current]);
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters);
+ Assert.AreSame(current, _fakeUia.LastInvokedElement,
+ "invoke must act on the element resolved after the queue wait, not before it");
+ }
+
+ [TestMethod]
+ public async Task Invoke_RefusesWhenTheTargetWindowClosedWhileQueued()
+ {
+ _fakeUia.FindSingleResult = new UiElement { Id = "btn", Selector = "btn", Name = "Button", WindowHandle = 4242 };
+ _fakeSystemQuery.ProcessIdForWindowResult = 0;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode("stale_element");
+ Assert.IsNull(_fakeUia.LastInvokedElement, "no pattern may be invoked on a dead target");
+ }
+
+ [TestMethod]
+ public async Task Focus_ReResolvesTheElementInsideTheDesktopSection()
+ {
+ var stale = new UiElement { Id = "box", Selector = "box", Name = "Stale", WindowHandle = 4242 };
+ var current = new UiElement { Id = "box", Selector = "box", Name = "Current", WindowHandle = 4242 };
+ _fakeUia.MovingResults["box"] = new Queue([stale, current]);
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["box", "-a", "TestApp", "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters);
+ Assert.AreSame(current, _fakeUia.LastFocusedElement);
+ }
+
+ // --------------------------------------------------------------- send-keys ordering fix (§13)
+
+ [TestMethod]
+ public async Task SendKeys_DoesNotFocusTheTargetWhenForegroundValidationFails()
+ {
+ // Spec §13: a command that fails foreground validation must not first apply avoidable focus to
+ // its target — that alone would dismiss another workflow's transient UI.
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242,
+ };
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+ _fakeForeground.Allow = false;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["hello", "-a", "TestApp", "--target", "box", "--via", "send-input", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.IsNull(_fakeUia.LastFocusedElement,
+ "focus must be applied only after the foreground has been verified");
+ Assert.AreEqual(0, _fakeKeyboard.SendCalls.Count);
+ }
+
+ [TestMethod]
+ public async Task SendKeys_FocusesTheTargetOnlyAfterForegroundIsVerified()
+ {
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242,
+ };
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+ _fakeForeground.Allow = true;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["hello", "-a", "TestApp", "--target", "box", "--via", "send-input", "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.IsNotNull(_fakeUia.LastFocusedElement);
+ Assert.AreEqual(1, _fakeKeyboard.SendCalls.Count);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters,
+ "revalidate, foreground, focus and send belong to one desktop section");
+ }
+
+ // --------------------------------------------- coordination faults must not become internal_error
+
+ [TestMethod]
+ public async Task Record_CoordinationFailureInsideTheBody_SurfacesTheCoordinationError()
+ {
+ // `ui record` opens its desktop section from inside the handler's broad catch-all. Without the
+ // IsCoordinationFault filter, an active.lock failure was reported as `internal_error` and — worse
+ // — looked to the coordinator like a normal body return, renewing the owner's idle grace.
+ _fakeRecording.RecordException = new UiCoordinationException(
+ UiCoordinationErrorCodes.Unavailable, "The UI desktop lock could not be opened.");
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "coordination-fault.mp4");
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode(UiCoordinationErrorCodes.Unavailable);
+ }
+
+ [TestMethod]
+ public async Task Record_OrdinaryFailureInsideTheBody_StaysWithTheHandler()
+ {
+ // The filter must be narrow: only coordination faults escape. An ordinary capture failure keeps
+ // its existing handler-owned envelope and never turns into a coordination error.
+ _fakeRecording.RecordException = new InvalidOperationException("encoder blew up");
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "ordinary-fault.mp4");
+ var command = GetRequiredService();
+
+ // Escaping to UiCoordinatedAction would not be caught there either, so an unhandled throw here
+ // is itself the failure signal.
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ var stderr = ConsoleStdErr.ToString();
+ StringAssert.Contains(stderr, "encoder blew up");
+ Assert.IsFalse(stderr.Contains(UiCoordinationErrorCodes.Unavailable, StringComparison.Ordinal),
+ $"an ordinary failure must not be reported as a coordination error; got: {stderr}");
+ }
+
+ // ------------------------------------------- pre-start recording cancellation must not renew grace
+
+ [TestMethod]
+ public async Task WaitFor_Cancelled_DoesNotReportItselfAsACompletedCommand()
+ {
+ // wait-for is the one polling command, so it is the one most likely to be interrupted: an agent
+ // that gives up on a slow app presses Ctrl+C mid-wait. Catching the cancellation and returning
+ // an exit code told the coordinator the command had completed, which renewed the owner's idle
+ // grace and kept foreign waiters queued behind a workflow that had already stopped. Worse, with
+ // --gone the swallowed cancellation could surface as "the element is gone" — a success.
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["Button1", "-a", "TestApp", "--json"], cts.Token);
+
+ Assert.IsTrue(_fakeDesktopLock.LastBodyThrew,
+ "the body must propagate the cancellation; returning any exit code makes the coordinator treat "
+ + "an interrupted wait as a completed command and renew the owner's grace");
+ Assert.IsInstanceOfType(_fakeDesktopLock.LastBodyException);
+ Assert.IsFalse(ConsoleStdErr.ToString().Contains("internal_error", StringComparison.Ordinal),
+ $"a cancellation is not an internal error (exit {exitCode})");
+ }
+
+ [TestMethod]
+ public async Task Record_CancelledBeforeCaptureStarted_DoesNotReportItselfAsACompletedCommand()
+ {
+ // The coordinator decides whether to renew the owner's idle grace from whether the body RETURNED
+ // or THREW. Swallowing a native cancellation here and returning 1 made a recording that produced
+ // nothing look like a completed command, so the workflow kept the desktop reserved for four more
+ // seconds on the strength of work it never did.
+ //
+ // System.CommandLine flattens a propagating OperationCanceledException to exit code 1 — the same
+ // code the old swallow path returned — so the exit code cannot distinguish them. The observable
+ // difference, and the one the coordinator actually acts on, is that the body THREW rather than
+ // returned. The coordinator half — 130, the `cancelled` envelope and no grace renewal — is
+ // asserted against the real coordinator in InteractiveDesktopLockTests.
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ _fakeRecording.RecordException = new OperationCanceledException(cts.Token);
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "cancelled-pre-start.mp4");
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command,
+ ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"],
+ cts.Token);
+
+ Assert.IsTrue(_fakeDesktopLock.LastBodyThrew,
+ "the body must propagate the cancellation; returning any exit code makes the coordinator treat "
+ + "a recording that produced nothing as a completed command and renew the owner's grace");
+ Assert.IsInstanceOfType(_fakeDesktopLock.LastBodyException);
+ Assert.IsFalse(File.Exists(outputPath), "a recording cancelled before capture produces no artifact");
+ Assert.IsFalse(ConsoleStdErr.ToString().Contains("internal_error", StringComparison.Ordinal),
+ $"a cancellation is not an internal error (exit {exitCode})");
+ }
+
+ [TestMethod]
+ public async Task Record_ThatFinalizesOnCancellationAndReturnsSuccess_StillCompletesNormally()
+ {
+ // The positive half of the same contract, kept explicit so the fix above cannot be "simplified"
+ // into propagating every cancellation: an ACTIVE recording observes Ctrl+C, finalizes its MP4 and
+ // returns success. That is a completed command and must keep renewing the owner's grace.
+ _fakeRecording.RecordResult = new RecordCaptureResult { Frames = 3, Width = 64, Height = 64, Mode = "wgc" };
+ _fakeRecording.RecordShouldWaitForCancellation = true;
+
+ using var stdin = new StringReader("stop");
+ UiRecordCommand.Handler.s_isInputRedirectedOverride = () => true;
+ UiRecordCommand.Handler.s_stdinOverride = stdin;
+ try
+ {
+ var outputPath = Path.Combine(_tempDirectory.FullName, "finalized.mp4");
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "0", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(0, exitCode, "a finalized recording reports success rather than cancellation");
+ Assert.IsTrue(File.Exists(outputPath));
+ }
+ finally
+ {
+ UiRecordCommand.Handler.s_isInputRedirectedOverride = null;
+ UiRecordCommand.Handler.s_stdinOverride = null;
+ _fakeRecording.RecordShouldWaitForCancellation = false;
+ }
+ }
+
+ // --------------------------------------------- send-input must re-verify foreground after focusing
+
+ [TestMethod]
+ public async Task SendKeys_ForegroundLostDuringFocus_RefusesToSend()
+ {
+ // FocusAsync is an awaited round-trip into the target's UI thread, and setting focus can itself
+ // activate another window. A published repro exited 0 while HELLO landed in a decoy window that
+ // the target's own focus event had activated. The check before focusing is therefore not enough —
+ // the last check before injection is the one that protects the user.
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242,
+ };
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+ _fakeForeground.Allow = true;
+
+ // First gate (before focus) passes; the second (after focus) denies, modelling the drift.
+ _fakeForeground.DenyOnCallNumber = 2;
+ _fakeForeground.DenyReason = ForegroundCheck.ForegroundNotTarget;
+ _fakeUia.OnFocus = () => { /* a focus handler activates a decoy window */ };
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["hello", "-a", "TestApp", "--target", "box", "--via", "send-input", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(0, _fakeKeyboard.SendCalls.Count,
+ "no keystrokes may be injected once the foreground drifted away from the target");
+ Assert.AreEqual(2, _fakeForeground.Calls.Count,
+ "the foreground must be verified again after focus, not only before it");
+ }
+
+ [TestMethod]
+ public async Task SendKeys_ForegroundHeldThroughFocus_Sends()
+ {
+ // The recheck must not become a false refusal on the ordinary path.
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242,
+ };
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+ _fakeForeground.Allow = true;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["hello", "-a", "TestApp", "--target", "box", "--via", "send-input", "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeKeyboard.SendCalls.Count);
+ }
+
+ [TestMethod]
+ public async Task SendKeys_PostMessage_IsUnaffectedByTheForegroundRecheck()
+ {
+ // post-message posts straight to the target HWND's queue, so it is not foreground-sensitive and
+ // must not acquire a new refusal path.
+ _fakeUia.FindSingleResult = new UiElement
+ {
+ Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242,
+ };
+ _fakeSystemQuery.ProcessIdForWindowResult = 1234;
+ _fakeForeground.Allow = false;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["hello", "-a", "TestApp", "--target", "box", "--via", "post-message", "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeKeyboard.SendCalls.Count);
+ Assert.AreEqual(0, _fakeForeground.Calls.Count, "post-message never consults the foreground guard");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.OutputPreflight.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.OutputPreflight.cs
new file mode 100644
index 000000000..71d4b169f
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.OutputPreflight.cs
@@ -0,0 +1,213 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Commands;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Output paths that can never work must be rejected before the command queues for the desktop.
+///
+///
+/// Both of these commands take a turn on a desktop shared with every other UI workflow on the machine
+/// — screenshot exclusively. A command whose destination is already impossible still waited for that
+/// turn, and made everyone else wait behind it, before failing on the last step. The fixture's
+/// DesktopSectionEnters and Runs counters are the assertion: zero of either means the
+/// command never reached coordination at all.
+///
+public partial class UiCommandTests
+{
+ private string ExistingDirectory(string name)
+ {
+ var path = Path.Combine(_tempDirectory.FullName, name);
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ ///
+ /// Asserts the JSON error envelope on stderr carries .
+ ///
+ ///
+ /// Scoped to the envelope line rather than reusing AssertJsonErrorCode, which parses from the
+ /// first brace to the end of the stream: a preflight failure also logs a human-readable line after
+ /// the envelope, and that trailing text is not JSON.
+ ///
+ private void AssertPreflightErrorCode(string expectedCode)
+ {
+ var stderr = ConsoleStdErr.ToString();
+ var envelope = stderr
+ .Split('\n')
+ .Select(line => line.Trim())
+ .FirstOrDefault(line => line.StartsWith('{'));
+
+ Assert.IsNotNull(envelope, $"stderr must contain a JSON error envelope; got: {stderr}");
+
+ var error = System.Text.Json.JsonSerializer.Deserialize(envelope);
+ Assert.AreEqual(expectedCode, error.GetProperty("error").GetProperty("code").GetString());
+ }
+
+ private void AssertNeverCoordinated(string because)
+ {
+ Assert.AreEqual(0, _fakeDesktopLock.Runs.Count, because);
+ Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters, because);
+ }
+
+ ///
+ /// Makes any attempt to capture pixels fail loudly, so a test that expects to be rejected during
+ /// preflight cannot quietly pass by failing later for a different reason.
+ ///
+ private void ArmCaptureTripwire()
+ => _fakeUia.ScreenshotThrow = new InvalidOperationException(
+ "capture must not be attempted for a command whose output path was already impossible");
+
+ // ------------------------------------------------------------------------------------ screenshot
+
+ [TestMethod]
+ public async Task Screenshot_OutputIsAnExistingDirectory_FailsBeforeTakingTheDesktop()
+ {
+ ArmCaptureTripwire();
+ var target = ExistingDirectory("shot-dir");
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "-o", target, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertPreflightErrorCode("invalid_arguments");
+ AssertNeverCoordinated("an unwritable destination must be refused before the desktop is taken");
+ // The tripwire is armed in each test below: reaching capture at all would surface as this.
+ }
+
+ [TestMethod]
+ public async Task Screenshot_OutputEndingInASeparator_FailsBeforeTakingTheDesktop()
+ {
+ ArmCaptureTripwire();
+ var target = Path.Combine(_tempDirectory.FullName, "not-a-file") + Path.DirectorySeparatorChar;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "-o", target, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertPreflightErrorCode("invalid_arguments");
+ AssertNeverCoordinated("a directory-shaped path is not a screenshot destination");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_ParentDirectoryIsAFile_FailsBeforeTakingTheDesktop()
+ {
+ ArmCaptureTripwire();
+ // The parent cannot be created because a file already occupies its name.
+ var blocker = Path.Combine(_tempDirectory.FullName, "blocker");
+ await File.WriteAllTextAsync(blocker, "not a directory", TestContext.CancellationToken);
+ var target = Path.Combine(blocker, "shot.png");
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "-o", target, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertPreflightErrorCode("invalid_arguments");
+ AssertNeverCoordinated("an uncreatable parent directory must be found before queueing");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_OverwritingAnExistingFileIsStillAllowed()
+ {
+ // Deliberate: a screenshot is cheap to retake and callers write to a fixed name in a loop.
+ var target = Path.Combine(_tempDirectory.FullName, "existing.png");
+ await File.WriteAllTextAsync(target, "old", TestContext.CancellationToken);
+ _fakeUia.ScreenshotResult = (new byte[4], 1, 1);
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "-o", target, "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, "a valid destination still takes its turn");
+ }
+
+ // ---------------------------------------------------------------------------------------- record
+
+ [TestMethod]
+ public async Task Record_ExplicitOutputAlreadyExists_FailsBeforeTakingTheDesktop()
+ {
+ // Previously only checked with --frames, so a plain recording queued for the desktop and then
+ // refused at the engine's no-clobber check, having made every other workflow wait first.
+ var target = Path.Combine(_tempDirectory.FullName, "taken.mp4");
+ await File.WriteAllTextAsync(target, "first take", TestContext.CancellationToken);
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", target, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertPreflightErrorCode("output_exists");
+ AssertNeverCoordinated("an existing recording must be found before the command queues");
+ Assert.AreEqual("first take", await File.ReadAllTextAsync(target, TestContext.CancellationToken),
+ "the existing take must be untouched");
+ }
+
+ [TestMethod]
+ public async Task Record_DerivedFramesDirectoryAlreadyExists_FailsBeforeTakingTheDesktop()
+ {
+ var target = Path.Combine(_tempDirectory.FullName, "with-frames.mp4");
+ Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, "with-frames.frames"));
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", target, "--frames", "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertPreflightErrorCode("output_exists");
+ AssertNeverCoordinated("a colliding frame directory must be found before the command queues");
+ }
+
+ [TestMethod]
+ public async Task Record_OutputIsAnExistingDirectory_FailsBeforeTakingTheDesktop()
+ {
+ var target = ExistingDirectory("rec-dir");
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", target, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertPreflightErrorCode("invalid_arguments");
+ AssertNeverCoordinated("a directory is not a recording destination");
+ }
+
+ [TestMethod]
+ public async Task Record_ParentDirectoryIsAFile_FailsBeforeTakingTheDesktop()
+ {
+ var blocker = Path.Combine(_tempDirectory.FullName, "rec-blocker");
+ await File.WriteAllTextAsync(blocker, "not a directory", TestContext.CancellationToken);
+ var target = Path.Combine(blocker, "take.mp4");
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", target, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertPreflightErrorCode("invalid_arguments");
+ AssertNeverCoordinated("an uncreatable parent directory must be found before queueing");
+ }
+
+ [TestMethod]
+ public async Task Record_WithNoExplicitOutput_StillCoordinatesNormally()
+ {
+ // The generated default carries a timestamp and a GUID, so it cannot collide and must not be
+ // treated as a preflight failure.
+ var previous = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(_tempDirectory.FullName);
+ try
+ {
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeDesktopLock.Runs.Count, "a default-path recording still takes its turn");
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(previous);
+ }
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Command.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Command.cs
index 03dc15274..04b436a47 100644
--- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Command.cs
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Command.cs
@@ -128,6 +128,100 @@ public async Task Record_Success_EmitsRecordResultJson()
Assert.IsTrue(File.Exists(outputPath), "record should have produced an output file");
}
+
+ [TestMethod]
+ public async Task Record_ReleasesDesktopSectionAfterStartedCallback()
+ {
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _fakeRecording.AfterRecordingStarted = () => started.SetResult();
+ _fakeRecording.WaitAfterRecordingStarted = unblock.Task;
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "started-release.mp4");
+ var command = GetRequiredService();
+ var running = ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]);
+
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ SpinWait.SpinUntil(() => _fakeDesktopLock.OpenDesktopSections == 0, TimeSpan.FromSeconds(5));
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters);
+ Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections);
+
+ unblock.SetResult();
+ Assert.AreEqual(0, await running);
+ }
+
+ [TestMethod]
+ public async Task Record_FailureBeforeStartedCallback_ReleasesDesktopSection()
+ {
+ _fakeRecording.RecordException = new InvalidOperationException("capture failed before first frame");
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "before-start.mp4");
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters);
+ Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections);
+ }
+
+ [TestMethod]
+ public async Task Record_DuplicateStartedCallback_DoesNotReleaseTwiceOrThrow()
+ {
+ _fakeRecording.InvokeRecordingStartedTwice = true;
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "duplicate-start.mp4");
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters);
+ Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections);
+ }
+
+ [TestMethod]
+ public async Task Record_PrintWindowMode_HoldsSectionForWholeRecordingAndWarns()
+ {
+ _fakeWindowCapture.Supported = false;
+ _fakeRecording.RecordResult = new RecordCaptureResult { Frames = 5, Width = 100, Height = 100, Mode = "printwindow" };
+ _fakeRecording.BeforeRecordingStarted = () => Assert.AreEqual(1, _fakeDesktopLock.OpenDesktopSections);
+ _fakeRecording.AfterRecordingStarted = () => Assert.AreEqual(1, _fakeDesktopLock.OpenDesktopSections);
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "printwindow-warning.mp4");
+ var command = GetRequiredService();
+ var (exitCode, ambientOutput) = await InvokeWithAmbientConsoleCaptureAsync(command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters);
+ Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections);
+ StringAssert.Contains(ambientOutput, "PrintWindow");
+
+ _fakeDesktopLock.Runs.Clear();
+ _fakeWindowCapture.Supported = false;
+ TestAnsiConsole.Clear(false);
+ outputPath = Path.Combine(_tempDirectory.FullName, "printwindow-warning-json.mp4");
+ exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ StringAssert.Contains(TestAnsiConsole.Output, "PrintWindow");
+ }
+
+ [TestMethod]
+ public async Task Record_PredictedModeDisagreement_IsSurfacedAsWarning()
+ {
+ _fakeWindowCapture.Supported = false;
+ _fakeRecording.RecordResult = new RecordCaptureResult { Frames = 5, Width = 100, Height = 100, Mode = "wgc" };
+
+ var outputPath = Path.Combine(_tempDirectory.FullName, "mode-disagreement.mp4");
+ var command = GetRequiredService();
+ var (exitCode, ambientOutput) = await InvokeWithAmbientConsoleCaptureAsync(command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath]);
+
+ Assert.AreEqual(0, exitCode);
+ StringAssert.Contains(ambientOutput, "coordination planned for 'printwindow'");
+ }
+
[TestMethod]
public async Task Record_JsonMode_EmitsLivenessEventToStderr_NotToStdout()
{
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Coordination.cs
new file mode 100644
index 000000000..6757f4066
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Coordination.cs
@@ -0,0 +1,170 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.CommandLine;
+using System.Text;
+using WinApp.Cli.Commands;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// How ui record hands the desktop back. Recording is the only command that keeps running
+/// after it stops needing exclusive use of the desktop, so the moment it releases the section — and
+/// what can delay that moment — is its own coverage area.
+///
+public partial class UiCommandTests
+{
+ ///
+ /// A whose first WriteLine blocks until the test releases it,
+ /// standing in for a caller that reads the command's stderr slowly (a full pipe buffer, a paused
+ /// consumer, a debugger-attached parent).
+ ///
+ private sealed class BlockingWriter : TextWriter
+ {
+ private readonly ManualResetEventSlim _release = new(false);
+ private readonly ManualResetEventSlim _entered = new(false);
+ private int _writes;
+
+ public override Encoding Encoding => Encoding.UTF8;
+
+ /// Waits until the command is actually blocked inside the write.
+ public bool WaitUntilBlocked(TimeSpan timeout) => _entered.Wait(timeout);
+
+ public void Release() => _release.Set();
+
+ public override void WriteLine(string? value)
+ {
+ if (Interlocked.Increment(ref _writes) == 1)
+ {
+ _entered.Set();
+ _release.Wait(TimeSpan.FromSeconds(30));
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _release.Set();
+ _release.Dispose();
+ _entered.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+
+ ///
+ /// A blocked "recording-started" write must not keep the desktop locked.
+ ///
+ ///
+ /// The engine raises its first-frame callback on the capture thread, and the CLI both signals its
+ /// own "the desktop is free now" gate and writes the liveness JSON from inside that callback. If
+ /// the write happens first, a caller that is slow to drain stderr pins the capture thread inside
+ /// the callback; the gate is never signalled, the recording task never completes, and the section
+ /// stays open for as long as the reader is slow — blocking every other winapp ui command on the
+ /// machine. Signalling first makes the release independent of the write, which is what this
+ /// asserts: the section is observed closed while the writer is still blocked.
+ ///
+ [TestMethod]
+ public async Task Record_BlockedStartedNotification_DoesNotHoldTheDesktopSection()
+ {
+ var outputPath = Path.Combine(_tempDirectory.FullName, "blocked-notify.mp4");
+
+ // WGC, not PrintWindow: PrintWindow deliberately holds the section for the whole recording,
+ // so only the frame-capture path exercises the first-frame handoff this test is about.
+ _fakeWindowCapture.Supported = true;
+
+ // Keep the fake recording running until the test says otherwise, so the only thing that can
+ // close the section is the first-frame handoff — not the recording simply ending.
+ var finishRecording = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _fakeRecording.WaitAfterRecordingStarted = finishRecording.Task;
+
+ using var blockingStderr = new BlockingWriter();
+ var command = GetRequiredService();
+ var parseResult = command.Parse(
+ ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]);
+ parseResult.InvocationConfiguration.Output = TestAnsiConsole.Profile.Out.Writer;
+ parseResult.InvocationConfiguration.Error = blockingStderr;
+
+ var invocation = Task.Run(
+ () => parseResult.InvokeAsync(parseResult.InvocationConfiguration, CancellationToken.None),
+ CancellationToken.None);
+
+ Assert.IsTrue(
+ blockingStderr.WaitUntilBlocked(TimeSpan.FromSeconds(20)),
+ "the command should have reached the recording-started write");
+
+ // The write is still blocked right now. The section must already be closed anyway.
+ var releasedWhileBlocked = await WaitForConditionAsync(
+ () => _fakeDesktopLock.OpenDesktopSections == 0,
+ TimeSpan.FromSeconds(10));
+
+ blockingStderr.Release();
+ finishRecording.TrySetResult();
+ var exitCode = await invocation;
+
+ Assert.IsTrue(
+ releasedWhileBlocked,
+ "the desktop section must be released from the first-frame signal alone; a caller that is " +
+ "slow to read stderr must not be able to hold the desktop.");
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, "recording takes exactly one section");
+ }
+
+ ///
+ /// A target whose handle was recycled while the command queued must be refused from inside the
+ /// section, before the engine is handed the stale handle.
+ ///
+ ///
+ /// The target is resolved before the command queues for the desktop. By the time the turn is
+ /// granted, the original window may have closed and Windows may have reused its handle for an
+ /// unrelated process — and a recording of the wrong application looks exactly like a correct one.
+ ///
+ [TestMethod]
+ public async Task Record_TargetHandleRecycledWhileQueued_RefusesAndRecordsNothing()
+ {
+ var outputPath = Path.Combine(_tempDirectory.FullName, "recycled.mp4");
+ _fakeWindowCapture.Supported = true;
+
+ const long hwnd = 777;
+ _fakeTargetResolver.TargetResult = new UiTarget
+ {
+ ProcessId = 1234,
+ ProcessName = "TestApp",
+ WindowTitle = "Test Window",
+ WindowHandle = hwnd,
+ };
+
+ // The window the command resolved is gone; the handle now belongs to an unrelated process
+ // and has no owner chain leading back to the expected one.
+ _fakeSystemQuery.ProcessIdByHwnd[hwnd] = 9999;
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode("stale_element");
+ Assert.IsFalse(File.Exists(outputPath), "a refused recording must not leave an MP4 behind");
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters,
+ "the check belongs inside the section — it is only meaningful once the turn is held");
+ Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections, "the section must still be released");
+ }
+
+ private static async Task WaitForConditionAsync(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (condition())
+ {
+ return true;
+ }
+
+ await Task.Delay(25, CancellationToken.None);
+ }
+
+ return condition();
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs
index 380568bb2..98d6269c3 100644
--- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs
@@ -4,6 +4,7 @@
using WinApp.Cli.Commands;
using WinApp.Cli.Helpers;
using WinApp.Cli.Services;
+using Microsoft.Extensions.Logging.Abstractions;
using System.Security.AccessControl;
using System.Security.Principal;
@@ -353,7 +354,7 @@ public async Task StdinMonitor_DisposedCts_NeverThrowsUnhandledException()
Interlocked.Exchange(ref unhandled, null);
var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None);
- var handler = new UiRecordCommand.Handler(null!, null!, null!, null!);
+ var handler = new UiRecordCommand.Handler(null!, null!, null!, null!, null!, null!, NullLogger.Instance);
cts.Dispose();
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs
new file mode 100644
index 000000000..ee5d49151
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs
@@ -0,0 +1,177 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Commands;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// --capture-screen against an app with more than one window.
+///
+///
+/// Live-screen capture reads pixels off the screen, so it records whatever is actually in front.
+/// Only one window can be in front, which makes "capture the screen for each of these five windows
+/// and composite them" unsatisfiable: at best it stitches together moments where each window covered
+/// the others, and in practice Windows refuses the second activation and the command dies with a bare
+/// foreground_not_target that says nothing about the real problem. The command has to name the
+/// ambiguity instead, before it foregrounds or captures anything.
+///
+public partial class UiCommandTests
+{
+ private const int MultiWindowPid = 7788;
+ private const nint FirstWindow = 0x200;
+ private const nint SecondWindow = 0x201;
+
+ ///
+ /// Points the command at an app whose process has two top-level windows.
+ ///
+ ///
+ /// Targeted by PID rather than by name: name lookup goes through the real process table, while
+ /// -a <pid> routes through FindWindowsByPid, which the fake drives.
+ ///
+ private void ArrangeTwoTopLevelWindows()
+ {
+ _fakeSystemQuery.ProcessIdForWindowResult = MultiWindowPid;
+ _fakeSystemQuery.ProcessIdByHwnd[FirstWindow] = MultiWindowPid;
+ _fakeSystemQuery.ProcessIdByHwnd[SecondWindow] = MultiWindowPid;
+ _fakeSystemQuery.WindowTextResult = "Main Window";
+ _fakeUia.WindowsByPidResult =
+ [
+ (FirstWindow, MultiWindowPid, "Main Window"),
+ (SecondWindow, MultiWindowPid, "Second Window"),
+ ];
+ _fakeUia.ScreenshotResult = (new byte[4], 1, 1);
+ }
+
+ private static string App => MultiWindowPid.ToString();
+
+ [TestMethod]
+ public async Task Screenshot_CaptureScreenWithSeveralWindows_IsRejectedBeforeAnyCapture()
+ {
+ ArrangeTwoTopLevelWindows();
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", App, "--capture-screen", "--json", "-o", ShotPath()]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode("invalid_arguments");
+ Assert.AreEqual(0, _fakeUia.ScreenshotCalls.Count,
+ "the failure must land before any window is foregrounded or captured");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_CaptureScreenAmbiguity_PointsAtListWindowsAndTheWindowFlag()
+ {
+ // The reviewer's report was that the generic foreground error left no way to work out what to
+ // do next. The recovery hint has to name both halves of the fix.
+ ArrangeTwoTopLevelWindows();
+ var command = GetRequiredService();
+
+ await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", App, "--capture-screen", "--json", "-o", ShotPath()]);
+
+ var stderr = ConsoleStdErr.ToString();
+ StringAssert.Contains(stderr, "list-windows");
+ StringAssert.Contains(stderr, "-w ");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_CaptureScreenForOneExplicitWindow_StillWorks()
+ {
+ // -w is the documented fix, so it must not be caught by the same guard.
+ ArrangeTwoTopLevelWindows();
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-w", SecondWindow.ToString(), "--capture-screen", "--json", "-o", ShotPath()]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeUia.ScreenshotCalls.Count);
+ Assert.IsTrue(_fakeUia.ScreenshotCalls[0].CaptureScreen);
+ }
+
+ [TestMethod]
+ public async Task Screenshot_SeveralWindowsWithoutCaptureScreen_StillComposites()
+ {
+ // Window-content capture does not read the screen, so several windows compose fine. Rejecting
+ // this too would have broken the feature the multi-window path exists for.
+ ArrangeTwoTopLevelWindows();
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", App, "--json", "-o", ShotPath()]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(2, _fakeUia.ScreenshotCalls.Count, "both windows must still be captured");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_CaptureScreenForOneExplicitWindowThatOwnsADialog_StillCaptures()
+ {
+ // The regression this guard nearly shipped. `-w ` is what the ambiguity error tells you
+ // to do, so it must not land back in the same error when the window you picked happens to own
+ // a dialog. Live-screen capture of a chosen window is one region, and a dialog sitting on top
+ // of it is already in those pixels — that is the reason to use --capture-screen at all.
+ ArrangeOwnedDialog(ownerOfDialog: AppWindow);
+ // The real resolver returns the window it was asked for; the fake needs telling, and this
+ // test is specifically about which HWND the single capture targets.
+ _fakeTargetResolver.TargetResult = new UiTarget
+ {
+ ProcessId = AppPid,
+ ProcessName = "TestApp",
+ WindowTitle = "Main Window",
+ WindowHandle = AppWindow,
+ };
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-w", AppWindow.ToString(), "--capture-screen", "--json", "-o", ShotPath()]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, _fakeUia.ScreenshotCalls.Count, "exactly one region is captured");
+ Assert.AreEqual((long)AppWindow, _fakeUia.ScreenshotCalls[0].Hwnd,
+ "and it is the window the caller explicitly selected, not the dialog");
+ Assert.IsTrue(_fakeUia.ScreenshotCalls[0].CaptureScreen);
+ }
+
+ [TestMethod]
+ public async Task Screenshot_CaptureScreenWithAnAppOwnedDialog_IsStillRejected()
+ {
+ // Discovery from `-a` can produce an app window plus a dialog it owns, and there the caller
+ // has not chosen between them — same ambiguity as several top-level windows, same guard.
+ _fakeSystemQuery.ProcessIdForWindowResult = MultiWindowPid;
+ _fakeSystemQuery.ProcessIdByHwnd[FirstWindow] = MultiWindowPid;
+ _fakeSystemQuery.ProcessIdByHwnd[OwnedDialog] = SystemHostPid;
+ _fakeSystemQuery.WindowOwnerByHwnd[OwnedDialog] = FirstWindow;
+ _fakeSystemQuery.WindowTextResult = "Main Window";
+ _fakeUia.WindowsByPidResult = [(FirstWindow, MultiWindowPid, "Main Window")];
+ _fakeWindowFinder.OwnedWindowsResult = [(OwnedDialog, SystemHostPid, "Save As")];
+ _fakeUia.ScreenshotResult = (new byte[4], 1, 1);
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", App, "--capture-screen", "--json", "-o", ShotPath()]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode("invalid_arguments");
+ Assert.AreEqual(0, _fakeUia.ScreenshotCalls.Count);
+ StringAssert.Contains(ConsoleStdErr.ToString(), "-w ");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_ExplicitWindowThatOwnsADialog_StillCompositesWithoutCaptureScreen()
+ {
+ // Window-content capture reads each window's own pixels, so a dialog is not already included
+ // and compositing it is the point. Only the live-screen path narrows to one region.
+ ArrangeOwnedDialog(ownerOfDialog: AppWindow);
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-w", AppWindow.ToString(), "--json", "-o", ShotPath()]);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(2, _fakeUia.ScreenshotCalls.Count,
+ "the owned dialog is still composited when not capturing the screen");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Provenance.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Provenance.cs
new file mode 100644
index 000000000..12779f993
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Provenance.cs
@@ -0,0 +1,124 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Commands;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Where a captured window came from, and why that is not the same question as who owns it now.
+///
+///
+/// A screenshot deliberately includes the dialogs an app owns — common-item file pickers, print and
+/// security dialogs — and those run in another process. So a captured window's current PID
+/// is not evidence of anything: for an owned dialog it is a shared system host that also runs
+/// dialogs for unrelated applications. Checking the handle against that PID accepts any live window
+/// in the host, including one whose handle was recycled after the real dialog closed. The check has
+/// to be against the application the window was discovered for, so the owner chain has to still
+/// reach it.
+///
+public partial class UiCommandTests
+{
+ private const int AppPid = 4321;
+ private const int SystemHostPid = 9100;
+ private const nint AppWindow = 0x100;
+ private const nint OwnedDialog = 0xD1A;
+
+ /// Points the command at a single app window that owns one cross-process dialog.
+ private void ArrangeOwnedDialog(nint ownerOfDialog)
+ {
+ _fakeSystemQuery.ProcessIdForWindowResult = AppPid;
+ _fakeSystemQuery.WindowTextResult = "Main Window";
+ _fakeSystemQuery.ProcessIdByHwnd[AppWindow] = AppPid;
+
+ // The dialog belongs to a shared system host, not to the app.
+ _fakeSystemQuery.ProcessIdByHwnd[OwnedDialog] = SystemHostPid;
+ _fakeSystemQuery.WindowOwnerByHwnd[OwnedDialog] = ownerOfDialog;
+
+ _fakeWindowFinder.OwnedWindowsResult = [(OwnedDialog, SystemHostPid, "Save As")];
+ _fakeUia.ScreenshotResult = (new byte[4], 1, 1);
+ }
+
+ private async Task CaptureOwnedDialogAsync()
+ {
+ var path = ShotPath();
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-w", AppWindow.ToString(), "--json", "-o", path]);
+
+ Assert.AreEqual(0, exitCode);
+ return System.Text.Json.JsonSerializer.Deserialize(TestAnsiConsole.Output);
+ }
+
+ [TestMethod]
+ public async Task Screenshot_LiveCrossProcessOwnedDialog_IsCaptured()
+ {
+ // The dialog runs in another process and is still owned by the app window, which is exactly the
+ // case the validation must not break: a file picker is part of the app's UI.
+ ArrangeOwnedDialog(ownerOfDialog: AppWindow);
+
+ var output = await CaptureOwnedDialogAsync();
+ var windows = output.GetProperty("windows").EnumerateArray().ToList();
+
+ var dialog = windows.Single(w => w.GetProperty("hwnd").GetInt64() == OwnedDialog);
+ Assert.IsTrue(dialog.GetProperty("captured").GetBoolean(),
+ "an owned dialog in another process is legitimately part of this app's UI");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_OwnedDialogHandleReusedInsideTheSameHost_IsNotCaptured()
+ {
+ // The regression. The real dialog closed and its handle was reused by an unrelated window in
+ // the SAME system host, so the handle still resolves to the host's PID and a check against
+ // that PID passes — while the owner link back to the app is gone. Under the old check this
+ // window was composited into an image labelled as this application's.
+ ArrangeOwnedDialog(ownerOfDialog: 0); // no owner: the relationship to the app no longer exists
+
+ var output = await CaptureOwnedDialogAsync();
+ var windows = output.GetProperty("windows").EnumerateArray().ToList();
+
+ Assert.IsFalse(
+ windows.Any(w => w.GetProperty("hwnd").GetInt64() == OwnedDialog),
+ "a handle that can no longer be traced back to the app must not be captured as its window");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_OwnedDialogNowOwnedByAnUnrelatedWindow_IsNotCaptured()
+ {
+ // Same shape, but the recycled handle has acquired a different owner rather than none. It still
+ // cannot be attributed to this application.
+ ArrangeOwnedDialog(ownerOfDialog: 0x999);
+ _fakeSystemQuery.ProcessIdByHwnd[0x999] = SystemHostPid;
+
+ var output = await CaptureOwnedDialogAsync();
+ var windows = output.GetProperty("windows").EnumerateArray().ToList();
+
+ Assert.IsFalse(
+ windows.Any(w => w.GetProperty("hwnd").GetInt64() == OwnedDialog),
+ "an owner outside the app's own windows proves nothing about provenance");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_DirectSamePidWindows_AreStillCaptured()
+ {
+ // The app's own windows expect their own process, so the stricter owned-window rule must not
+ // catch them.
+ _fakeUia.WindowsByPidResult = [((nint)41, AppPid, "Main"), ((nint)42, AppPid, "Tool")];
+ _fakeSystemQuery.ProcessIdByHwnd[41] = AppPid;
+ _fakeSystemQuery.ProcessIdByHwnd[42] = AppPid;
+ _fakeUia.ScreenshotResult = (new byte[4], 1, 1);
+
+ var path = ShotPath();
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(
+ command, ["-a", AppPid.ToString(), "--json", "-o", path]);
+
+ Assert.AreEqual(0, exitCode);
+ var output = System.Text.Json.JsonSerializer.Deserialize(TestAnsiConsole.Output);
+ var windows = output.GetProperty("windows").EnumerateArray().ToList();
+
+ Assert.HasCount(2, windows);
+ Assert.IsTrue(windows.All(w => w.GetProperty("captured").GetBoolean()),
+ "an app's own windows are validated against their own process and must still be captured");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs
index a4261d7bc..d0e1bfcbc 100644
--- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs
@@ -3,6 +3,7 @@
using System.Diagnostics;
using WinApp.Cli.Commands;
+using WinApp.Cli.Helpers;
namespace WinApp.Cli.Tests;
@@ -44,6 +45,10 @@ public async Task Screenshot_MultiWindowByPid_Json_EmitsWindowsArray()
{
// Integer --app → FindWindowsByPid; two windows → composite multi-window capture.
_fakeUia.WindowsByPidResult = [((nint)11, 4321, "Main"), ((nint)12, 4321, "Dialog")];
+ // Both handles really do belong to the discovered process, which is what the OS reports when
+ // the command revalidates them before capturing.
+ _fakeSystemQuery.ProcessIdByHwnd[11] = 4321;
+ _fakeSystemQuery.ProcessIdByHwnd[12] = 4321;
_fakeUia.ScreenshotResult = (new byte[4], 1, 1);
var path = ShotPath();
@@ -54,6 +59,44 @@ public async Task Screenshot_MultiWindowByPid_Json_EmitsWindowsArray()
StringAssert.Contains(TestAnsiConsole.Output, "\"windows\":");
StringAssert.Contains(TestAnsiConsole.Output, "\"captured\": true");
Assert.IsTrue(File.Exists(path));
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters,
+ "the whole multi-window capture pass must run inside one desktop section");
+ Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections,
+ "the section must be closed before the command reports the written file");
+ }
+
+ [TestMethod]
+ public async Task Screenshot_MultiWindow_RecycledHandle_IsSkippedNotComposited()
+ {
+ // Every handle in the composite was discovered before the command queued for the desktop. If
+ // one of those windows closed while waiting and Windows reused its handle, capturing it would
+ // splice an unrelated application's pixels into an image reported as this target's — and the
+ // per-window "captured" flag would claim it succeeded.
+ _fakeUia.WindowsByPidResult = [((nint)31, 4321, "Main"), ((nint)32, 4321, "Doomed")];
+ _fakeSystemQuery.ProcessIdByHwnd[31] = 4321;
+ _fakeSystemQuery.ProcessIdByHwnd[32] = 9999; // recycled onto an unrelated process
+ _fakeUia.ScreenshotResult = (new byte[4], 1, 1);
+ var path = ShotPath();
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "4321", "--json", "-o", path]);
+
+ Assert.AreEqual(0, exitCode, "the surviving window is still captured");
+
+ var output = System.Text.Json.JsonSerializer.Deserialize(TestAnsiConsole.Output);
+ var windows = output.GetProperty("windows").EnumerateArray().ToList();
+ Assert.HasCount(2, windows, "both windows are reported, so the omission is visible");
+
+ var recycled = windows.Single(w => w.GetProperty("hwnd").GetInt64() == 32);
+ Assert.IsFalse(recycled.GetProperty("captured").GetBoolean(),
+ "a recycled handle must never be reported as captured");
+ StringAssert.Contains(recycled.GetProperty("error").GetString()!, "different process");
+
+ var survivor = windows.Single(w => w.GetProperty("hwnd").GetInt64() == 31);
+ Assert.IsTrue(survivor.GetProperty("captured").GetBoolean());
+
+ Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters,
+ "the whole multi-window capture pass must run inside one desktop section");
}
[TestMethod]
@@ -62,6 +105,8 @@ public async Task Screenshot_MultiWindowByName_NonJson_Composites()
// Exact current-process-name match → real process enumeration hits FindWindowsByPid per process.
var procName = Process.GetCurrentProcess().ProcessName;
_fakeUia.WindowsByPidResult = [((nint)21, 100, "A"), ((nint)22, 100, "B")];
+ _fakeSystemQuery.ProcessIdByHwnd[21] = 100;
+ _fakeSystemQuery.ProcessIdByHwnd[22] = 100;
_fakeUia.ScreenshotResult = (new byte[4], 1, 1);
var path = ShotPath();
@@ -89,6 +134,20 @@ public async Task Screenshot_MultiWindowAllCapturesFail_ReturnsError()
StringAssert.Contains(TestAnsiConsole.Output, "✗");
}
+ [TestMethod]
+ public async Task Screenshot_ForegroundLost_WritesNoArtifact()
+ {
+ _fakeUia.ScreenshotThrow = new ForegroundLostException("target lost foreground");
+ var path = ShotPath();
+
+ var command = GetRequiredService();
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--capture-screen", "--json", "-o", path]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCodeIn(ConsoleStdErr.ToString(), UiJsonError.CodeForegroundNotTarget);
+ Assert.IsFalse(File.Exists(path));
+ }
+
[TestMethod]
public async Task Screenshot_SingleWindowWithOwnedDialog_Composites()
{
@@ -197,6 +256,9 @@ public async Task Screenshot_WindowHandleValid_WithOwnedDialog_CompositesThrough
_fakeSystemQuery.ProcessIdForWindowResult = 4321;
_fakeSystemQuery.WindowTextResult = "Main Window";
_fakeWindowFinder.OwnedWindowsResult = [((nint)0xD1A, 4321, "Owned Dialog")];
+ // A real owned dialog reports the app window as its GW_OWNER; that link is what attributes it
+ // to this application rather than to whatever process happens to host it.
+ _fakeSystemQuery.WindowOwnerByHwnd[0xD1A] = (nint)2748;
_fakeUia.ScreenshotResult = (new byte[4], 1, 1);
var path = ShotPath();
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Yield.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Yield.cs
new file mode 100644
index 000000000..b16359439
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Yield.cs
@@ -0,0 +1,114 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Commands;
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Command-level coverage of winapp ui yield: the outcomes coordination reports must reach the
+/// caller as the right exit code, error code and JSON shape, and yield must never register itself as a
+/// participant of the turn it is releasing.
+///
+public partial class UiCommandTests
+{
+ [TestMethod]
+ public async Task Yield_ReleasesWithoutTakingATurnOrTheDesktop()
+ {
+ // Yield gives a turn back. Taking one to do it would make it the very live command that stops
+ // the turn being idle, so it must never appear as a coordinated run or open a desktop section.
+ _fakeDesktopLock.YieldResult = UiYieldResult.Released;
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ StringAssert.Contains(TestAnsiConsole.Output, "\"released\": true");
+ Assert.AreEqual(1, _fakeDesktopLock.YieldCalls.Count);
+ Assert.AreEqual(0, _fakeDesktopLock.Runs.Count, "yield must not take a turn of its own");
+ Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters, "yield must never take active.lock");
+ }
+
+ [TestMethod]
+ public async Task Yield_WithNothingHeld_SucceedsAndReportsItReleasedNothing()
+ {
+ // Yielding after the grace already lapsed, or twice, is the normal end of a script.
+ _fakeDesktopLock.YieldResult = UiYieldResult.NothingHeld;
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]);
+
+ Assert.AreEqual(0, exitCode);
+ StringAssert.Contains(TestAnsiConsole.Output, "\"released\": false");
+ }
+
+ [TestMethod]
+ public async Task Yield_WithoutAWorkflowId_FailsWithInvalidArguments()
+ {
+ // Deliberately not invalid_ui_workflow_id: that code means the variable is present but
+ // malformed, and reporting it here would send the caller hunting for a value they never set.
+ _fakeDesktopLock.YieldResult = UiYieldResult.NotAWorkflow;
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode("invalid_arguments");
+ StringAssert.Contains(ConsoleStdErr.ToString(), UiOwnerResolver.WorkflowIdVariable);
+ }
+
+ [TestMethod]
+ public async Task Yield_WhileTheWorkflowIsStillBusy_FailsWithTurnBusy()
+ {
+ _fakeDesktopLock.YieldResult = UiYieldResult.Busy;
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode(UiCoordinationErrorCodes.TurnBusy);
+ }
+
+ [TestMethod]
+ public async Task Yield_WhenCoordinationIsUnavailable_SurfacesTheCoordinationError()
+ {
+ _fakeDesktopLock.ThrowOnYield = new UiCoordinationException(
+ UiCoordinationErrorCodes.Unavailable, "state is unreadable", "update winapp");
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]);
+
+ Assert.AreEqual(1, exitCode);
+ AssertJsonErrorCode(UiCoordinationErrorCodes.Unavailable);
+ }
+
+ [TestMethod]
+ public async Task Yield_TextOutput_NeverRevealsTheWorkflowId()
+ {
+ _fakeDesktopLock.YieldResult = UiYieldResult.Released;
+ var command = GetRequiredService();
+
+ var exitCode = await ParseAndInvokeWithCaptureAsync(command, []);
+
+ Assert.AreEqual(0, exitCode);
+ var output = TestAnsiConsole.Output + ConsoleStdErr;
+ Assert.IsFalse(output.Contains('{'), "text mode must not emit the JSON envelope");
+ Assert.IsFalse(
+ output.Contains(UiOwnerResolver.WorkflowIdVariable + "=", StringComparison.Ordinal),
+ "the workflow id itself is never echoed back");
+ }
+
+ [TestMethod]
+ public void Yield_TakesNoAppOrSelector()
+ {
+ // It releases a reservation, not a window: requiring -a would make the last step of a workflow
+ // depend on an app that may already have closed.
+ var command = GetRequiredService();
+
+ Assert.AreEqual(0, command.Arguments.Count);
+ Assert.IsFalse(
+ command.Options.Any(o => o.Name is "--app" or "--window"),
+ "yield targets no app");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs
index 24488bfa1..bce562652 100644
--- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs
@@ -3,8 +3,10 @@
using Microsoft.Extensions.DependencyInjection;
using WinApp.Cli.Commands;
+using WinApp.Cli.Helpers;
using WinApp.Cli.Models;
using WinApp.Cli.Services;
+using WinApp.Cli.Services.InteractiveDesktop;
namespace WinApp.Cli.Tests;
@@ -22,6 +24,9 @@ public partial class UiCommandTests : BaseCommandTests
private FakeOwnedWindowFinder _fakeWindowFinder = null!;
private FakeSystemUiQuery _fakeSystemQuery = null!;
private FakePollDelay _fakePollDelay = null!;
+ private FakeInteractiveDesktopLock _fakeDesktopLock = null!;
+ private FakeDesktopForegroundService _fakeDesktopForeground = null!;
+ private FakeWindowCapture _fakeWindowCapture = null!;
private void AssertJsonErrorCode(string expectedCode)
=> AssertJsonErrorCodeIn(ConsoleStdErr.ToString(), expectedCode);
@@ -45,8 +50,11 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi
_fakeForeground = new FakeForegroundGuard();
_fakePointer = new FakePointerInput();
_fakeWindowFinder = new FakeOwnedWindowFinder();
- _fakeSystemQuery = new FakeSystemUiQuery();
+ _fakeSystemQuery = new FakeSystemUiQuery { ProcessIdForWindowResult = 1234 };
_fakePollDelay = new FakePollDelay();
+ _fakeDesktopLock = new FakeInteractiveDesktopLock();
+ _fakeDesktopForeground = new FakeDesktopForegroundService();
+ _fakeWindowCapture = new FakeWindowCapture();
return services
.AddSingleton(_fakeUia)
.AddSingleton(_fakeRecording)
@@ -57,7 +65,10 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi
.AddSingleton(_fakePointer)
.AddSingleton(_fakeWindowFinder)
.AddSingleton(_fakeSystemQuery)
- .AddSingleton(_fakePollDelay);
+ .AddSingleton(_fakePollDelay)
+ .AddSingleton(_fakeDesktopLock)
+ .AddSingleton(_fakeDesktopForeground)
+ .AddSingleton(_fakeWindowCapture);
}
[TestMethod]
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiOwnerResolverTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiOwnerResolverTests.cs
new file mode 100644
index 000000000..4c7b3f376
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/UiOwnerResolverTests.cs
@@ -0,0 +1,124 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Services.InteractiveDesktop;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// How a workflow id becomes an owner key ().
+///
+///
+/// The key is the only thing that decides whether two commands share the desktop, so two different
+/// ids producing one key is a correctness failure, not a hashing nicety: the workflows would take
+/// each other's turns and interleave on the same desktop while each believed it was alone.
+///
+[TestClass]
+[DoNotParallelize] // WINAPP_UI_WORKFLOW_ID is process-wide, so these must not race other classes.
+public class UiOwnerResolverTests : IDisposable
+{
+ private string? _previousWorkflowId;
+
+ [TestInitialize]
+ public void Setup()
+ => _previousWorkflowId = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable);
+
+ [TestCleanup]
+ public void Cleanup()
+ => Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, _previousWorkflowId);
+
+ public void Dispose() => GC.SuppressFinalize(this);
+
+ private static UiOwnerIdentity ResolveWith(string? workflowId)
+ {
+ Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, workflowId);
+ return new UiOwnerResolver().Resolve();
+ }
+
+ // ------------------------------------------------------------------ ill-formed UTF-16 is not text
+
+ ///
+ /// Built from char values in code rather than passed as [DataRow] constants: attribute
+ /// arguments are stored as UTF-8 in assembly metadata, so a lone surrogate written there is
+ /// substituted before the test ever runs and the case silently tests nothing.
+ ///
+ private static IEnumerable<(string Value, string Description)> IllFormedWorkflowIds()
+ {
+ const char high = '\ud800';
+ const char low = '\udc00';
+ yield return (high.ToString(), "lone high surrogate");
+ yield return (low.ToString(), "lone low surrogate");
+ yield return ("wf-" + '\ud801' + "-tail", "lone high surrogate inside a longer id");
+ yield return ("lead" + '\udfff', "lone low surrogate at the end");
+ }
+
+ [TestMethod]
+ public void AnUnpairedSurrogateIsRefusedInsteadOfBeingSubstituted()
+ {
+ // Encoding these the ordinary way substitutes U+FFFD, which is why they must be refused: the
+ // substitution is lossy in exactly the direction that matters, mapping distinct ids onto one
+ // owner rather than onto distinct owners.
+ foreach (var (value, description) in IllFormedWorkflowIds())
+ {
+ var ex = Assert.ThrowsExactly(
+ () => ResolveWith(value), $"{description} must be refused");
+
+ Assert.AreEqual(UiCoordinationErrorCodes.InvalidWorkflowId, ex.Code, description);
+ StringAssert.Contains(ex.Message, "surrogate", description);
+ }
+ }
+
+ [TestMethod]
+ public void DistinctIllFormedIdsWouldOtherwiseCollideOnOneKey()
+ {
+ // The specific failure the refusal prevents. Under replacement encoding all three of these
+ // become the same bytes, so all three become the same owner — two unrelated workflows and one
+ // caller who legitimately used U+FFFD, sharing one turn on one desktop.
+ Assert.ThrowsExactly(() => ResolveWith('\ud800'.ToString()));
+ Assert.ThrowsExactly(() => ResolveWith('\ud801'.ToString()));
+
+ // U+FFFD itself is an ordinary character and stays valid, so the two above cannot reach it.
+ var replacementChar = ResolveWith("\ufffd");
+ Assert.AreEqual(UiOwnerKind.Workflow, replacementChar.Kind);
+ }
+
+ // ------------------------------------------------------------------------- well-formed stays valid
+
+ [TestMethod]
+ public void WellFormedIdsIncludingSurrogatePairsResolveToDistinctWorkflowOwners()
+ {
+ string[] ids =
+ [
+ "plain-workflow",
+ "550e8400-e29b-41d4-a716-446655440000",
+ "\ud83d\ude80", // one astral character: a PAIR, not a lone surrogate
+ "\ufffd",
+ ];
+
+ var keys = new HashSet(StringComparer.Ordinal);
+ foreach (var id in ids)
+ {
+ var owner = ResolveWith(id);
+ Assert.AreEqual(UiOwnerKind.Workflow, owner.Kind, $"'{id}' must resolve to a workflow owner");
+ Assert.IsTrue(keys.Add(owner.Key), $"'{id}' must not collide with another id");
+ }
+ }
+
+ [TestMethod]
+ public void TheKeyForAnOrdinaryIdIsUnchanged()
+ {
+ // Golden values: the strict encoding must not alter the key for text that was always valid, or
+ // every running workflow would be re-homed on upgrade. These are SHA-256 of
+ // "winapp-ui-workflow-v1\0" + the id, computed independently of this code.
+ //
+ // Computed directly rather than through the environment variable, which is process-wide and
+ // therefore the one input a parallel test could change underneath this assertion.
+ Assert.AreEqual(
+ "af0babd78c14cae807477f0a3085bfd1ad91a9b37d89733766dbf32af0dcc328",
+ UiOwnerResolver.ComputeWorkflowKey("550e8400-e29b-41d4-a716-446655440000"));
+
+ Assert.AreEqual(
+ "99c43e974bc265169b8fac2a24c905b019dfa8e1aea94eeb47dafb3e9d24d929",
+ UiOwnerResolver.ComputeWorkflowKey("plain-workflow"));
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinary.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinary.cs
new file mode 100644
index 000000000..3da21000d
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinary.cs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Runtime.InteropServices;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Locates the published winapp.exe that the gated multiprocess and real-app coordination
+/// suites launch as real child processes.
+///
+///
+///
+/// The canonical build publishes both artifacts/cli/win-x64 and artifacts/cli/win-arm64,
+/// and CI downloads the whole cli-binaries artifact, so BOTH runtime identifiers are on disk at
+/// once. Probing them in a fixed order therefore picks by luck rather than by architecture: an
+/// arm64-first walk hands an ARM64 executable to an x64 runner and every
+/// fails. That is
+/// invisible on an arm64 dev box, where the wrong-order lookup happens to be right.
+///
+///
+/// Resolution is therefore driven by the host architecture and is fail-closed: only the matching RID is
+/// ever returned, never a different one. Shared by both suites so the rule cannot drift between them.
+///
+///
+internal static class WinappTestBinary
+{
+ /// How far up from the test output directory to look for the artifacts folder.
+ private const int MaxParentLevels = 8;
+
+ ///
+ /// The publish RID for . Pure, so the choice itself is unit-testable
+ /// without a published binary or a second machine.
+ ///
+ ///
+ /// The canonical build publishes only x64 and arm64, so any other architecture has no binary to run
+ /// and must fail loudly rather than silently falling back to one that cannot execute.
+ ///
+ internal static string RidFor(Architecture architecture) => architecture switch
+ {
+ Architecture.X64 => "win-x64",
+ Architecture.Arm64 => "win-arm64",
+ _ => throw new PlatformNotSupportedException(
+ $"No winapp.exe is published for {architecture}; the gated UI coordination suites need win-x64 or win-arm64."),
+ };
+
+ ///
+ /// The RID this machine can launch. Uses the OS architecture rather than the test process's own:
+ /// an arm64 Windows host runs arm64 natively (and x64 only under emulation), while an x64 host
+ /// cannot run arm64 at all, so the OS architecture is the one that is always executable.
+ ///
+ internal static string CurrentRid => RidFor(RuntimeInformation.OSArchitecture);
+
+ ///
+ /// The published winapp.exe for this host, or when it is not present.
+ ///
+ ///
+ /// RIDs that were found but are not runnable here — the signature of an architecture mismatch rather
+ /// than a missing build.
+ ///
+ internal static string? TryFind(out IReadOnlyList foundOtherRids)
+ {
+ var requiredRid = CurrentRid;
+ var otherRids = new List();
+ var root = AppContext.BaseDirectory;
+
+ for (var i = 0; i < MaxParentLevels && root is not null; i++)
+ {
+ var cliRoot = Path.Combine(root, "artifacts", "cli");
+
+ var candidate = Path.Combine(cliRoot, requiredRid, "winapp.exe");
+ if (File.Exists(candidate))
+ {
+ foundOtherRids = [];
+ return candidate;
+ }
+
+ // Only recorded for diagnostics — a non-matching RID is never returned.
+ if (Directory.Exists(cliRoot))
+ {
+ foreach (var rid in new[] { "win-x64", "win-arm64" })
+ {
+ if (rid != requiredRid && File.Exists(Path.Combine(cliRoot, rid, "winapp.exe")))
+ {
+ otherRids.Add(rid);
+ }
+ }
+ }
+
+ root = Path.GetDirectoryName(root.TrimEnd(Path.DirectorySeparatorChar));
+ }
+
+ // Last resort for a binary copied next to the test assembly, which is built for this host.
+ var sideBySide = Path.Combine(AppContext.BaseDirectory, "winapp.exe");
+ if (File.Exists(sideBySide))
+ {
+ foundOtherRids = [];
+ return sideBySide;
+ }
+
+ foundOtherRids = otherRids;
+ return null;
+ }
+
+ ///
+ /// The published winapp.exe for this host, or a precise failure explaining which one is
+ /// missing.
+ ///
+ ///
+ /// A build that produced the other architecture is a hard configuration error and fails the
+ /// test, because silently skipping would report the suite as "not run" when the real problem is that
+ /// it can never run here. No build at all stays inconclusive, which is the ordinary local-dev state;
+ /// the CI step additionally fails the job on any skip, so neither case can pass unnoticed.
+ ///
+ internal static string Resolve()
+ {
+ var path = TryFind(out var otherRids);
+ if (path is not null)
+ {
+ return path;
+ }
+
+ if (otherRids.Count > 0)
+ {
+ Assert.Fail(
+ $"winapp.exe was published for {string.Join(", ", otherRids)} but not for {CurrentRid}, "
+ + $"which is the only architecture this {RuntimeInformation.OSArchitecture} host can launch. "
+ + "Publish the matching runtime identifier (scripts\\build-cli.ps1 builds both).");
+ }
+
+ throw new AssertInconclusiveException(
+ $"winapp.exe was not found. Run scripts\\build-cli.ps1 first so artifacts\\cli\\{CurrentRid}\\winapp.exe exists.");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinaryTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinaryTests.cs
new file mode 100644
index 000000000..3a1aca7e3
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinaryTests.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Runtime.InteropServices;
+
+namespace WinApp.Cli.Tests;
+
+///
+/// Architecture-aware resolution of the published winapp.exe the gated coordination suites
+/// launch ().
+///
+///
+/// The canonical build publishes both RIDs and CI downloads both, so a fixed-order probe picks by luck.
+/// An arm64-first walk handed an ARM64 binary to the x64 CI runner while looking correct on an arm64 dev
+/// box — the failure mode these tests exist to prevent. The RID choice is a pure function precisely so
+/// both architectures can be asserted from one machine.
+///
+[TestClass]
+public class WinappTestBinaryTests
+{
+ private static readonly string[] SupportedRids = ["win-x64", "win-arm64"];
+
+ [TestMethod]
+ public void X64HostSelectsTheX64Binary()
+ {
+ Assert.AreEqual("win-x64", WinappTestBinary.RidFor(Architecture.X64));
+ }
+
+ [TestMethod]
+ public void Arm64HostSelectsTheArm64Binary()
+ {
+ Assert.AreEqual("win-arm64", WinappTestBinary.RidFor(Architecture.Arm64));
+ }
+
+ [TestMethod]
+ public void EachArchitectureSelectsADifferentRid()
+ {
+ // The whole bug was that both architectures resolved to the same (arm64) binary.
+ Assert.AreNotEqual(
+ WinappTestBinary.RidFor(Architecture.X64),
+ WinappTestBinary.RidFor(Architecture.Arm64));
+ }
+
+ [TestMethod]
+ public void AnUnpublishedArchitectureFailsRatherThanGuessing()
+ {
+ // Falling back to a binary that cannot execute here would surface as an opaque Process.Start
+ // failure deep inside a coordination test.
+ Assert.ThrowsExactly(() => WinappTestBinary.RidFor(Architecture.X86));
+ }
+
+ [TestMethod]
+ public void CurrentRidMatchesThisHostAndIsSupported()
+ {
+ var rid = WinappTestBinary.CurrentRid;
+
+ Assert.AreEqual(WinappTestBinary.RidFor(RuntimeInformation.OSArchitecture), rid);
+ CollectionAssert.Contains(SupportedRids, rid);
+ }
+
+ [TestMethod]
+ public void ResolutionNeverReturnsABinaryForAnotherArchitecture()
+ {
+ // Guards the fail-closed property directly: whatever is on disk, the path handed to
+ // Process.Start is either this host's RID or nothing at all.
+ var path = WinappTestBinary.TryFind(out _);
+ if (path is null)
+ {
+ return;
+ }
+
+ var directory = Path.GetFileName(Path.GetDirectoryName(path));
+ var otherRid = WinappTestBinary.CurrentRid == "win-x64" ? "win-arm64" : "win-x64";
+ Assert.AreNotEqual(otherRid, directory,
+ $"resolution returned a {otherRid} binary on a {WinappTestBinary.CurrentRid} host");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs
index c3e5561aa..83e9938c0 100644
--- a/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs
+++ b/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs
@@ -10,6 +10,7 @@
using WinApp.Cli.Helpers;
using WinApp.Cli.Models;
using WinApp.Cli.Services;
+using WinApp.Cli.Services.InteractiveDesktop;
namespace WinApp.Cli.Commands;
@@ -46,12 +47,21 @@ public class Handler(
IUiSelectorParser selectorParser,
IMouseInput mouseInput,
IForegroundGuard foregroundGuard,
+ IDesktopForegroundService desktopForeground,
+ ISystemUiQuery systemQuery,
IAnsiConsole ansiConsole,
- ILogger logger) : AsynchronousCommandLineAction
+ IInteractiveDesktopLock desktopLock,
+ ILogger logger) : UiCoordinatedAction(desktopLock, logger)
{
/// Cursor-settle pause (ms) before the final confirm read and button-down.
private const int CursorSettleMs = 50;
- public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default)
+
+ protected override string Operation => "ui click";
+
+ /// A click drives the shared cursor and OS-wide SendInput stream.
+ protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive;
+
+ protected override int? Preflight(ParseResult parseResult)
{
var json = parseResult.GetValue(WinAppRootCommand.JsonOption);
var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument);
@@ -70,6 +80,16 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio
return 1;
}
+ return null;
+ }
+
+ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken)
+ {
+ var json = parseResult.GetValue(WinAppRootCommand.JsonOption);
+ // Preflight rejected a missing selector, so this is non-null by construction.
+ var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!;
+ var app = parseResult.GetValue(SharedUiOptions.AppOption);
+ var window = parseResult.GetValue(SharedUiOptions.WindowOption);
var doubleClick = parseResult.GetValue(DoubleClickOption);
var rightClick = parseResult.GetValue(RightClickOption);
@@ -87,10 +107,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio
var clickType = doubleClick ? "double-click" : rightClick ? "right-click" : "click";
- // Get element center from bounding rect
- int centerX = (int)(element.X + element.Width / 2.0);
- int centerY = (int)(element.Y + element.Height / 2.0);
-
if (element.Width == 0 || element.Height == 0)
{
logger.LogError("{Symbol} Element has zero size — cannot click.", UiSymbols.Error);
@@ -98,70 +114,67 @@ public override async Task