mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-30 18:59:23 +00:00
feat: uia element wrapper & tree walk (sub-phase 2.2) (#114)
Fills crates/windows/src/tree/ with the four things every later observation sub-phase consumes: an element wrapper with delegated refcounting, a timeout-bounded UIA client and window-root resolver, a tree walk with a cycle guard and honest completeness, and provider-class-conditional CacheRequest batching. Ships the secure-field gate, an out-of-process fixture, committed COM censuses as dev-box evidence, and corrections to docs/phases.md. Measurement changed the design repeatedly, and each result is a ledger row rather than a silent correction: - A14-3: the end-of-list discriminator is not inverted - code() 0 with result() None, on both builds. - A14-4/A14-9: a dead provider is invisible on both the sibling and property axes, so neither carries liveness. Recorded for 2.4, which owns the snapshot path. - A14-8: IsOffscreen disagrees with itself inside one minimized window, contradicting A1-2 off the managed stack. - A14-10: 2.1's owned-process COM bootstrap is thread-local behind a process-wide guard. - A14-11/A14-12: ElementFromHandle against a non-dispatching window hangs rather than timing out, and the bound that stops it is reachable only from CUIAutomation8. There is no fallback to an unbounded client. - A14-13: UI Automation's client core initialises lazily and not re-entrantly, so concurrent first touches fail with E_FAIL. The accessor serialises that first touch. Every control was observed failing before being claimed: the cycle guard, the discriminator, the secure-field gate, error redaction, capture normalisation, fixture teardown and both hang bounds. ObservationOps::observe_tree still returns PLATFORM_NOT_SUPPORTED; no ref is allocated anywhere in crates/windows.
This commit is contained in:
parent
00a4282f19
commit
41fc178e5e
50 changed files with 8244 additions and 45 deletions
47
.github/workflows/windows-capability-probe.yml
vendored
Normal file
47
.github/workflows/windows-capability-probe.yml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
name: Windows capability probe
|
||||
|
||||
# Measure-before-design evidence for the Windows adapter sub-phases. The
|
||||
# trigger is pull_request, not only workflow_dispatch: GitHub dispatches a
|
||||
# workflow only when the file exists on the default branch, and every Windows
|
||||
# sub-phase lands on feat/windows-adapter rather than on main, so a dispatch
|
||||
# trigger alone would never run on the branch that needs the measurement. The
|
||||
# path filter keeps this job off every unrelated pull request.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'probes/windows/14-ci-capability/**'
|
||||
- '.github/workflows/windows-capability-probe.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-capability-probe-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
capability-probe:
|
||||
name: Measure the hosted Windows runner
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install pinned Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
# Windows PowerShell 5.1, matching the rest of the probe corpus; pwsh is
|
||||
# not used anywhere in probes/windows.
|
||||
- name: Run the capability probe
|
||||
shell: powershell
|
||||
run: |
|
||||
.\probes\windows\14-ci-capability\probe.ps1 -Label ci
|
||||
|
||||
- name: Upload the measured captures
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-capability-probe-captures
|
||||
path: probes/windows/14-ci-capability/captures/*-ci.json
|
||||
if-no-files-found: error
|
||||
37
CLAUDE.md
37
CLAUDE.md
|
|
@ -41,6 +41,43 @@ Bypass for an emergency commit with `git commit --no-verify` or `SKIP_PRECOMMIT=
|
|||
|
||||
Cross-platform Rust CLI + MCP server enabling AI agents to observe and control desktop applications via native OS accessibility trees.
|
||||
|
||||
## Source of Truth & Sync (Non-Negotiable)
|
||||
|
||||
Two documents govern every phase, and they are never allowed to disagree.
|
||||
|
||||
- **`docs/phases.md` is the source of truth for the product.** Scope, exit criteria, invariants, API mappings, dependency pins and phase order live there. What the product *is* and *will be* is settled by that document.
|
||||
- **The sub-phase plan under `docs/plans/` is the source of truth for implementation and review.** How a sub-phase is built, what its units are, and what its Verification Contract and Definition of Done require are settled by the plan.
|
||||
- **They must be in sync at every commit, and both must match the code.** A statement in one that contradicts the other, or contradicts what shipped, is a defect in its own right — not documentation debt to be tidied later. The next sub-phase's planner reads these documents as fact; a stale line becomes a wrong decision.
|
||||
|
||||
### Planning: contradictions are corrected on discovery
|
||||
|
||||
Research routinely disproves statements in `docs/phases.md`. When it does:
|
||||
|
||||
- **Correct `docs/phases.md` in the same PR that discovered it.** Never plan around a statement known to be false, and never leave the correction to a later sub-phase.
|
||||
- **Correct in place; never annotate.** Rewrite the statement so the document reads true. No "previously said X", no "NOTE:", no changelog line — the document is the product's source of truth, not its history.
|
||||
- **Cite what disproved it** — a `probes/**/FINDINGS.md` row id, or the verified source.
|
||||
- The plan carries the correction as its own implementation unit, so it is reviewed alongside the work.
|
||||
|
||||
### Implementation: build it, do not defer it
|
||||
|
||||
**Deferral is the exception and the bar is high. The default is that the implementer makes it work.**
|
||||
|
||||
- **Attempt the work fully before concluding it cannot be done.** "Harder than expected", "the plan underestimated it", "a later sub-phase touches this anyway", and "the PR is already large" are **not** grounds for deferral.
|
||||
- A deferral is justified only when the work is genuinely blocked: technically impossible on the platform, dependent on infrastructure that does not exist yet, or completable only by violating a stated invariant or safety property. Name which one applies.
|
||||
- **Measure before deferring.** If the obstacle is an unknown, settle it with a probe or an experiment first. A deferral resting on an assumption is not a deferral, it is a guess — and an assumption that turns out to be wrong has usually hidden work that was a few hours away.
|
||||
- Reducing scope is the owner's call, not the implementer's. If the work genuinely cannot land, say so explicitly and stop; do not narrow the deliverable quietly.
|
||||
|
||||
### Every deferral updates `docs/phases.md` immediately
|
||||
|
||||
When something is deferred — at planning time or at implementation time — the sub-phase that now owns it is updated in the **same PR**, before that PR is opened for review:
|
||||
|
||||
- Write the deferred work into the **receiving** sub-phase's scope in `docs/phases.md`, in enough detail that its implementer can act on it without reading this PR or its plan.
|
||||
- State what was learned that forces it there, and cite the evidence.
|
||||
- If the deferral changes what the **originating** sub-phase delivers, correct that sub-phase's scope and exit criteria too, so it never claims work it did not ship.
|
||||
- A ledger row, a PR description, or a plan's residual list is **not** sufficient. Those are read by this PR's reviewer; `docs/phases.md` is read by the next sub-phase's planner, and that is who needs to know.
|
||||
|
||||
**A PR that defers work without updating `docs/phases.md` is incomplete and must not be merged.**
|
||||
|
||||
## Git & Commits
|
||||
|
||||
### Branching during a platform phase (Phase 2 = Windows, in progress)
|
||||
|
|
|
|||
311
Cargo.lock
generated
311
Cargo.lock
generated
|
|
@ -88,7 +88,10 @@ name = "agent-desktop-windows"
|
|||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"agent-desktop-core",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"uiautomation",
|
||||
"windows",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
|
|
@ -101,6 +104,15 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
|
|
@ -151,6 +163,12 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
|
|
@ -163,12 +181,41 @@ version = "2.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
|
|
@ -255,6 +302,12 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
|
|
@ -282,12 +335,60 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
|
|
@ -300,6 +401,17 @@ version = "1.0.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
|
|
@ -342,6 +454,15 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
|
|
@ -401,6 +522,12 @@ version = "2.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
|
|
@ -453,6 +580,18 @@ dependencies = [
|
|||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
|
|
@ -569,6 +708,29 @@ dependencies = [
|
|||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uiautomation"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c68495a701b9f2f21f29353ac446f0d27dd0d7ce97aa9ccf9061bca0446cd744"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"uiautomation_derive",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uiautomation_derive"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffcc4d404aa1c03a848f95cf5feadc3e63946d7f095bf388770b85550093d388"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
@ -587,12 +749,152 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
|
@ -602,6 +904,15 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
|
|
|||
|
|
@ -7,9 +7,18 @@ publish = false
|
|||
|
||||
[dependencies]
|
||||
agent-desktop-core.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
uiautomation = { version = "0.25", default-features = false, features = [
|
||||
"control",
|
||||
"input",
|
||||
] }
|
||||
windows = { version = "0.62.2", features = [
|
||||
"Win32_System_Com",
|
||||
"Win32_UI_Accessibility",
|
||||
] }
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
|
|
@ -17,7 +26,11 @@ windows-sys = { version = "0.61", features = [
|
|||
"Win32_Storage_FileSystem",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_System_ApplicationInstallationAndServicing",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
|
|
|
|||
77
crates/windows/examples/uia_tree_dump.rs
Normal file
77
crates/windows/examples/uia_tree_dump.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! Dev-box evidence tool: dumps a window's UI Automation tree as JSON through
|
||||
//! the same COM client, walker, property reader and cache policy the adapter
|
||||
//! ships.
|
||||
//!
|
||||
//! Sub-phase 2.0's committed dumps were taken on the **managed** stack, which
|
||||
//! A2-4 measured reporting the identical Notepad window as 3 nodes against 26
|
||||
//! on the COM stack. This tool exists so 2.2's evidence is in the stack the
|
||||
//! product actually uses, and it records the target variant, OS build and
|
||||
//! client stack that 2.0's dumps lacked.
|
||||
//!
|
||||
//! Host data is normalised before writing, as the 2.0 captures already do:
|
||||
//! process ids, provider ids, window handles and user paths are substituted,
|
||||
//! and `Name` is recorded as presence and length only.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p agent-desktop-windows --example uia_tree_dump -- \
|
||||
//! --class Notepad --variant "classic Win32 Notepad" --out notepad-com.json
|
||||
//! ```
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "uia_tree_dump/select.rs"]
|
||||
mod select;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "uia_tree_dump/render.rs"]
|
||||
mod render;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn main() -> std::process::ExitCode {
|
||||
match run() {
|
||||
Ok(path) => {
|
||||
println!("wrote {path}");
|
||||
std::process::ExitCode::SUCCESS
|
||||
}
|
||||
Err(reason) => {
|
||||
eprintln!("{}", render::skipped(&reason));
|
||||
std::process::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run() -> Result<String, String> {
|
||||
use agent_desktop_core::Deadline;
|
||||
use agent_desktop_windows::tree::automation::root_from_hwnd;
|
||||
|
||||
let options = select::Options::from_args()?;
|
||||
agent_desktop_windows::ensure_owned_process_mta_and_dpi().map_err(|error| {
|
||||
format!(
|
||||
"the COM apartment could not be established: {}",
|
||||
error.message
|
||||
)
|
||||
})?;
|
||||
|
||||
let handle = select::find_window(&options)?;
|
||||
let deadline = Deadline::after(30_000).map_err(|error| error.message.clone())?;
|
||||
let root = root_from_hwnd(handle, deadline).map_err(|error| {
|
||||
format!(
|
||||
"the selected window did not resolve to a UI Automation root: {} ({})",
|
||||
error.message,
|
||||
error.platform_detail.unwrap_or_default()
|
||||
)
|
||||
})?;
|
||||
|
||||
let document = render::dump(&root, &options, deadline)?;
|
||||
std::fs::write(&options.output, document).map_err(|error| error.to_string())?;
|
||||
Ok(options.output.clone())
|
||||
}
|
||||
|
||||
/// The example is compiled by `cargo check --all-targets` on every lane, and
|
||||
/// every item above is gated behind Windows because it reaches the
|
||||
/// target-gated `uiautomation` dependency. This stub is what keeps the Linux
|
||||
/// and macOS gates green.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn main() {
|
||||
eprintln!("uia_tree_dump captures UI Automation evidence and runs on Windows only");
|
||||
}
|
||||
308
crates/windows/examples/uia_tree_dump/render.rs
Normal file
308
crates/windows/examples/uia_tree_dump/render.rs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
use agent_desktop_core::{Deadline, ObservationRoot, ProcessId, WindowInfo, WindowState};
|
||||
use agent_desktop_windows::tree::element::UIAElement;
|
||||
use agent_desktop_windows::tree::properties::{
|
||||
PropertyOutcome, PropertyValue, read_live, read_one,
|
||||
};
|
||||
use agent_desktop_windows::tree::property_ids::TreeProperty;
|
||||
use agent_desktop_windows::tree::walker::{TreeSource, WalkBudget};
|
||||
use agent_desktop_windows::tree::walker_source::{UiaTreeSource, walk_uia_subtree};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::select::Options;
|
||||
|
||||
/// Substituted for every run-varying host value, matching the placeholders the
|
||||
/// 2.0 captures already use.
|
||||
const REDACTED_PID: &str = "<pid>";
|
||||
const REDACTED_PROVIDER: &str = "<providerid>";
|
||||
const REDACTED_PATH: &str = "<userprofile>";
|
||||
|
||||
/// Reports an unresolvable target as a structured skip.
|
||||
///
|
||||
/// Never a silent empty dump: a capture containing nothing reads as evidence
|
||||
/// that the tree was empty.
|
||||
pub fn skipped(reason: &str) -> String {
|
||||
json!({ "status": "skipped", "reason": reason }).to_string()
|
||||
}
|
||||
|
||||
/// Replaces every run-varying or host-identifying value in a provider string.
|
||||
///
|
||||
/// `ProviderDescription` embeds a process id and a hexadecimal provider id,
|
||||
/// and a user path can appear in a module path, so the substitution runs
|
||||
/// before anything reaches the capture file. Each key consumes its **whole**
|
||||
/// value: a substitution that stops at the first non-digit leaves the tail of
|
||||
/// a `0x`-prefixed id in the file, which is a leak that reads as redacted.
|
||||
fn normalise(text: &str) -> String {
|
||||
let substituted = substitute_after(text, "pid:", REDACTED_PID);
|
||||
let substituted = substitute_after(&substituted, "providerId:", REDACTED_PROVIDER);
|
||||
redact_paths(&substituted)
|
||||
}
|
||||
|
||||
fn substitute_after(text: &str, key: &str, replacement: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find(key) {
|
||||
out.push_str(&rest[..at + key.len()]);
|
||||
let value = &rest[at + key.len()..];
|
||||
let consumed = value
|
||||
.find(|character: char| !(character.is_ascii_alphanumeric()))
|
||||
.unwrap_or(value.len());
|
||||
if consumed == 0 {
|
||||
rest = value;
|
||||
continue;
|
||||
}
|
||||
out.push_str(replacement);
|
||||
rest = &value[consumed..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_paths(text: &str) -> String {
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
match lowered.find(r"\users\") {
|
||||
None => text.to_string(),
|
||||
Some(at) => {
|
||||
let tail = &text[at + r"\users\".len()..];
|
||||
let end = tail.find('\\').unwrap_or(tail.len());
|
||||
format!("{}{}{}", &text[..at], REDACTED_PATH, &tail[end..])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(outcome: &PropertyOutcome) -> Option<String> {
|
||||
match outcome {
|
||||
PropertyOutcome::Known(PropertyValue::Text(value)) => Some(value.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn slot(outcome: &PropertyOutcome) -> Value {
|
||||
match outcome {
|
||||
PropertyOutcome::Known(PropertyValue::Text(value)) => json!(normalise(value)),
|
||||
PropertyOutcome::Known(PropertyValue::Flag(value)) => json!(value),
|
||||
PropertyOutcome::Known(PropertyValue::Number(value)) => json!(value),
|
||||
PropertyOutcome::Known(PropertyValue::Bounds(bounds)) => json!({
|
||||
"x": bounds.x, "y": bounds.y, "width": bounds.width, "height": bounds.height
|
||||
}),
|
||||
PropertyOutcome::Absent => json!("<absent>"),
|
||||
PropertyOutcome::Unknown => json!("<unknown>"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Name` is recorded as presence and length only, per the corpus redaction
|
||||
/// rule: a document node's name is user content.
|
||||
fn name_presence(outcome: &PropertyOutcome) -> Value {
|
||||
match text_of(outcome) {
|
||||
Some(value) if value.is_empty() => json!({ "present": true, "chars": 0 }),
|
||||
Some(value) => json!({ "present": true, "chars": value.chars().count() }),
|
||||
None => json!({ "present": false, "outcome": slot(outcome) }),
|
||||
}
|
||||
}
|
||||
|
||||
fn node(element: &UIAElement, parent: Option<usize>, index: usize) -> Value {
|
||||
let (properties, errors) = read_live(element);
|
||||
json!({
|
||||
"parent_index": parent,
|
||||
"child_index": index,
|
||||
"control_type": slot(&read_one(element, TreeProperty::ControlType)),
|
||||
"class_name": slot(&properties.get(TreeProperty::ClassName)),
|
||||
"automation_id": slot(&properties.get(TreeProperty::AutomationId)),
|
||||
"name": name_presence(&properties.get(TreeProperty::Name)),
|
||||
"bounds": slot(&properties.get(TreeProperty::BoundingRectangle)),
|
||||
"is_password": slot(&properties.get(TreeProperty::IsPassword)),
|
||||
"is_offscreen": slot(&properties.get(TreeProperty::IsOffscreen)),
|
||||
"provider_description": slot(&read_one(element, TreeProperty::ProviderDescription)),
|
||||
"failed_reads": errors.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where one node sits in the dump being built.
|
||||
struct Position {
|
||||
parent: Option<usize>,
|
||||
index: usize,
|
||||
depth: u8,
|
||||
}
|
||||
|
||||
fn collect(
|
||||
source: &UiaTreeSource,
|
||||
element: &UIAElement,
|
||||
at: Position,
|
||||
max_depth: u8,
|
||||
nodes: &mut Vec<Value>,
|
||||
) {
|
||||
let position = nodes.len();
|
||||
nodes.push(node(element, at.parent, at.index));
|
||||
if at.depth >= max_depth {
|
||||
return;
|
||||
}
|
||||
let mut child = match source.first_child(element) {
|
||||
Ok(first) => first,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut child_index = 0;
|
||||
loop {
|
||||
let next = source.next_sibling(&child);
|
||||
collect(
|
||||
source,
|
||||
&child,
|
||||
Position {
|
||||
parent: Some(position),
|
||||
index: child_index,
|
||||
depth: at.depth + 1,
|
||||
},
|
||||
max_depth,
|
||||
nodes,
|
||||
);
|
||||
child_index += 1;
|
||||
match next {
|
||||
Ok(sibling) => child = sibling,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks the target and renders the capture.
|
||||
///
|
||||
/// The traversal runs on the shipped `TreeSource`, so the dump exercises the
|
||||
/// same enumeration surface and cache policy the adapter uses and can never
|
||||
/// reach the banned `get_children`.
|
||||
pub fn dump(root: &UIAElement, options: &Options, deadline: Deadline) -> Result<String, String> {
|
||||
let source = UiaTreeSource::for_root(root).map_err(|error| error.message.clone())?;
|
||||
let prepared = source
|
||||
.prepare_root(root)
|
||||
.map_err(|error| error.message.clone())?;
|
||||
let mut nodes = Vec::new();
|
||||
collect(
|
||||
&source,
|
||||
&prepared,
|
||||
Position {
|
||||
parent: None,
|
||||
index: 0,
|
||||
depth: 0,
|
||||
},
|
||||
options.max_depth,
|
||||
&mut nodes,
|
||||
);
|
||||
let document = json!({
|
||||
"capture": "uia-tree-census",
|
||||
"client_stack": "uia3-com",
|
||||
"crate": "uiautomation 0.25.0",
|
||||
"target_class": options.class_name,
|
||||
"target_variant": options.variant,
|
||||
"os_build": os_build(),
|
||||
"max_depth": options.max_depth,
|
||||
"node_count": nodes.len(),
|
||||
"walk": walk_verdict(&prepared, deadline),
|
||||
"control_types": control_type_census(&nodes),
|
||||
"providers": provider_census(&nodes),
|
||||
"sample": sample(&nodes),
|
||||
});
|
||||
serde_json::to_string_pretty(&document).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// One row per `ControlType` the target exposes.
|
||||
///
|
||||
/// This is what 2.3's role map and 2.4's surface detection actually read. A
|
||||
/// full per-node dump carried the same information a hundred times over -
|
||||
/// measured on Explorer, 100 nodes spread across 23 control types, 40 class
|
||||
/// names and 15 provider strings, with 85 of the provider strings verbatim
|
||||
/// repeats and every bounds value unassertable by rule (R9, KTD8).
|
||||
fn control_type_census(nodes: &[Value]) -> Value {
|
||||
let mut rows: BTreeMap<String, (usize, BTreeSet<String>, usize, usize)> = BTreeMap::new();
|
||||
for node in nodes {
|
||||
let key = node["control_type"].to_string();
|
||||
let row = rows.entry(key).or_insert((0, BTreeSet::new(), 0, 0));
|
||||
row.0 += 1;
|
||||
row.1.insert(node["class_name"].to_string());
|
||||
row.2 += usize::from(node["automation_id"].as_str() != Some("<absent>"));
|
||||
row.3 += usize::from(node["name"]["present"].as_bool().unwrap_or(false));
|
||||
}
|
||||
Value::Array(
|
||||
rows.into_iter()
|
||||
.map(|(control_type, (count, class_names, with_id, with_name))| {
|
||||
json!({
|
||||
"control_type": control_type,
|
||||
"nodes": count,
|
||||
"class_names": class_names.into_iter().collect::<Vec<_>>(),
|
||||
"with_automation_id": with_id,
|
||||
"with_name": with_name,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// One row per distinct provider, which is what the cache policy keys on.
|
||||
fn provider_census(nodes: &[Value]) -> Value {
|
||||
let mut rows: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for node in nodes {
|
||||
*rows
|
||||
.entry(node["provider_description"].to_string())
|
||||
.or_default() += 1;
|
||||
}
|
||||
Value::Array(
|
||||
rows.into_iter()
|
||||
.map(|(provider, count)| json!({ "provider": provider, "nodes": count }))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// One fully-detailed node per control type, so the shape stays inspectable
|
||||
/// without repeating it for every sibling.
|
||||
fn sample(nodes: &[Value]) -> Value {
|
||||
let mut seen = BTreeSet::new();
|
||||
Value::Array(
|
||||
nodes
|
||||
.iter()
|
||||
.filter(|node| seen.insert(node["control_type"].to_string()))
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn os_build() -> String {
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/c", "ver"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| String::from_utf8(output.stdout).ok())
|
||||
.map(|text| text.trim().to_string())
|
||||
.unwrap_or_else(|| String::from("<unrecorded>"))
|
||||
}
|
||||
|
||||
/// Runs the shipped walker over the same target and reports its verdict.
|
||||
///
|
||||
/// This is the evidence that matters beyond the node list: whether the
|
||||
/// traversal the adapter ships reports a real application's tree **complete**.
|
||||
/// An inverted discriminator or a swallowed enumeration fault shows up here as
|
||||
/// a false `true`, which is why the capture records the failure count beside
|
||||
/// it rather than the verdict alone.
|
||||
fn walk_verdict(root: &UIAElement, deadline: Deadline) -> Value {
|
||||
let window = WindowInfo {
|
||||
id: String::from("<hwnd>"),
|
||||
title: String::new(),
|
||||
app: String::new(),
|
||||
pid: ProcessId::from(0_u32),
|
||||
process_instance: None,
|
||||
bounds: None,
|
||||
state: WindowState {
|
||||
is_focused: false,
|
||||
minimized: None,
|
||||
visible: Some(true),
|
||||
},
|
||||
};
|
||||
match walk_uia_subtree(
|
||||
root,
|
||||
&ObservationRoot::Window(&window),
|
||||
WalkBudget::new(u8::MAX, deadline),
|
||||
) {
|
||||
Ok(outcome) => json!({
|
||||
"structurally_complete": outcome.tree.is_complete(),
|
||||
"nodes_observed": outcome.tree.node_count(),
|
||||
"enumeration_failures": outcome.failures.len(),
|
||||
"cycles_skipped": outcome.stats.traversal.cycles_skipped,
|
||||
}),
|
||||
Err(error) => json!({ "failed": error.message }),
|
||||
}
|
||||
}
|
||||
104
crates/windows/examples/uia_tree_dump/select.rs
Normal file
104
crates/windows/examples/uia_tree_dump/select.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
use std::ffi::c_void;
|
||||
|
||||
use windows_sys::Win32::Foundation::HWND;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
EnumWindows, GetClassNameW, GetWindowTextLengthW, IsWindowVisible,
|
||||
};
|
||||
|
||||
/// What the operator asked to capture.
|
||||
pub struct Options {
|
||||
pub class_name: String,
|
||||
pub variant: String,
|
||||
pub output: String,
|
||||
pub max_depth: u8,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn from_args() -> Result<Self, String> {
|
||||
let mut class_name = None;
|
||||
let mut variant = None;
|
||||
let mut output = None;
|
||||
let mut max_depth = 24_u8;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(flag) = args.next() {
|
||||
let mut value = || args.next().ok_or_else(|| format!("{flag} needs a value"));
|
||||
match flag.as_str() {
|
||||
"--class" => class_name = Some(value()?),
|
||||
"--variant" => variant = Some(value()?),
|
||||
"--out" => output = Some(value()?),
|
||||
"--max-depth" => {
|
||||
max_depth = value()?
|
||||
.parse()
|
||||
.map_err(|_| String::from("--max-depth needs a number"))?;
|
||||
}
|
||||
other => return Err(format!("unrecognised argument {other}")),
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
class_name: class_name
|
||||
.ok_or_else(|| String::from("--class names the window class to capture"))?,
|
||||
variant: variant.ok_or_else(|| {
|
||||
String::from("--variant records which build of the target this is, per C-9")
|
||||
})?,
|
||||
output: output.ok_or_else(|| String::from("--out names the capture file"))?,
|
||||
max_depth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Search {
|
||||
wanted: Vec<u16>,
|
||||
found: isize,
|
||||
}
|
||||
|
||||
fn class_of(window: HWND) -> Vec<u16> {
|
||||
let mut buffer = [0_u16; 256];
|
||||
let read = unsafe { GetClassNameW(window, buffer.as_mut_ptr(), buffer.len() as i32) };
|
||||
if read <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
buffer[..read as usize].to_vec()
|
||||
}
|
||||
|
||||
unsafe extern "system" fn visit(
|
||||
window: HWND,
|
||||
state: windows_sys::Win32::Foundation::LPARAM,
|
||||
) -> windows_sys::core::BOOL {
|
||||
let search = unsafe { &mut *(state as *mut Search) };
|
||||
if unsafe { IsWindowVisible(window) } == 0 {
|
||||
return 1;
|
||||
}
|
||||
if unsafe { GetWindowTextLengthW(window) } == 0 {
|
||||
return 1;
|
||||
}
|
||||
if class_of(window) != search.wanted {
|
||||
return 1;
|
||||
}
|
||||
search.found = window as isize;
|
||||
0
|
||||
}
|
||||
|
||||
/// Finds one visible, titled top-level window of the requested class.
|
||||
///
|
||||
/// A target that is not running is reported as skipped by the caller rather
|
||||
/// than dumped as an empty tree: a capture that silently contains nothing is
|
||||
/// worse than no capture, because it reads as evidence.
|
||||
pub fn find_window(options: &Options) -> Result<isize, String> {
|
||||
let mut search = Search {
|
||||
wanted: options.class_name.encode_utf16().collect(),
|
||||
found: 0,
|
||||
};
|
||||
unsafe {
|
||||
EnumWindows(
|
||||
Some(visit),
|
||||
&mut search as *mut Search as *mut c_void as isize,
|
||||
)
|
||||
};
|
||||
if search.found == 0 {
|
||||
return Err(format!(
|
||||
"no visible top-level window of class {} is running",
|
||||
options.class_name
|
||||
));
|
||||
}
|
||||
Ok(search.found)
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ mod actions;
|
|||
mod adapter;
|
||||
mod input;
|
||||
mod system;
|
||||
mod tree;
|
||||
pub mod tree;
|
||||
|
||||
pub use adapter::WindowsAdapter;
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
|
|||
73
crates/windows/src/system/hresult.rs
Normal file
73
crates/windows/src/system/hresult.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! The one place a COM status code is named.
|
||||
//!
|
||||
//! Both the permission probe and the UI Automation tree path classify and
|
||||
//! format HRESULTs. Holding two tables meant adding a code in one and reading
|
||||
//! it as unnamed in the other, so the table lives here and both import it.
|
||||
|
||||
pub(crate) const S_OK: i32 = 0;
|
||||
pub(crate) const E_NOINTERFACE: i32 = 0x8000_4002_u32 as i32;
|
||||
pub(crate) const E_POINTER: i32 = 0x8000_4003_u32 as i32;
|
||||
pub(crate) const E_FAIL: i32 = 0x8000_4005_u32 as i32;
|
||||
pub(crate) const E_ACCESSDENIED: i32 = 0x8007_0005_u32 as i32;
|
||||
pub(crate) const E_INVALIDARG: i32 = 0x8007_0057_u32 as i32;
|
||||
pub(crate) const CO_E_NOTINITIALIZED: i32 = 0x8004_01F0_u32 as i32;
|
||||
pub(crate) const RPC_E_SERVERFAULT: i32 = 0x8001_0105_u32 as i32;
|
||||
pub(crate) const RPC_E_DISCONNECTED: i32 = 0x8001_0108_u32 as i32;
|
||||
pub(crate) const RPC_S_SERVER_UNAVAILABLE: i32 = 0x8007_06BA_u32 as i32;
|
||||
pub(crate) const RPC_S_CALL_FAILED: i32 = 0x8007_06BE_u32 as i32;
|
||||
pub(crate) const UIA_E_ELEMENTNOTENABLED: i32 = 0x8004_0200_u32 as i32;
|
||||
pub(crate) const UIA_E_ELEMENTNOTAVAILABLE: i32 = 0x8004_0201_u32 as i32;
|
||||
pub(crate) const UIA_E_NOCLICKABLEPOINT: i32 = 0x8004_0202_u32 as i32;
|
||||
pub(crate) const UIA_E_PROXYASSEMBLYNOTLOADED: i32 = 0x8004_0203_u32 as i32;
|
||||
pub(crate) const UIA_E_NOTSUPPORTED: i32 = 0x8004_0204_u32 as i32;
|
||||
pub(crate) const UIA_E_TIMEOUT: i32 = 0x8013_1505_u32 as i32;
|
||||
pub(crate) const UIA_E_INVALIDOPERATION: i32 = 0x8013_1509_u32 as i32;
|
||||
|
||||
/// Renders an HRESULT for `platform_detail`.
|
||||
///
|
||||
/// Shape only: a code and, where one is known, its symbol and meaning. No
|
||||
/// entry derives from an observed application.
|
||||
pub(crate) fn com_hresult_detail(hresult: i32) -> String {
|
||||
let code = hresult as u32;
|
||||
match com_hresult_symbol(hresult) {
|
||||
Some((symbol, meaning)) => format!("COM HRESULT 0x{code:08X} ({symbol}: {meaning})"),
|
||||
None => format!("COM HRESULT 0x{code:08X}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names the HRESULTs this crate's COM paths can raise.
|
||||
///
|
||||
/// An unlisted code formats as a bare hexadecimal value rather than being
|
||||
/// guessed at.
|
||||
pub(crate) fn com_hresult_symbol(hresult: i32) -> Option<(&'static str, &'static str)> {
|
||||
let symbol = match hresult {
|
||||
E_ACCESSDENIED => ("E_ACCESSDENIED", "Access is denied"),
|
||||
E_NOINTERFACE => ("E_NOINTERFACE", "No such interface supported"),
|
||||
E_POINTER => ("E_POINTER", "Invalid pointer"),
|
||||
E_FAIL => ("E_FAIL", "Unspecified failure"),
|
||||
E_INVALIDARG => ("E_INVALIDARG", "One or more arguments are invalid"),
|
||||
CO_E_NOTINITIALIZED => ("CO_E_NOTINITIALIZED", "COM has not been initialized"),
|
||||
RPC_E_SERVERFAULT => ("RPC_E_SERVERFAULT", "The server raised an exception"),
|
||||
RPC_E_DISCONNECTED => ("RPC_E_DISCONNECTED", "The object invoked has disconnected"),
|
||||
RPC_S_SERVER_UNAVAILABLE => ("RPC_S_SERVER_UNAVAILABLE", "The RPC server is unavailable"),
|
||||
RPC_S_CALL_FAILED => ("RPC_S_CALL_FAILED", "The remote procedure call failed"),
|
||||
UIA_E_ELEMENTNOTENABLED => ("UIA_E_ELEMENTNOTENABLED", "The element is not enabled"),
|
||||
UIA_E_ELEMENTNOTAVAILABLE => ("UIA_E_ELEMENTNOTAVAILABLE", "The element is not available"),
|
||||
UIA_E_NOCLICKABLEPOINT => (
|
||||
"UIA_E_NOCLICKABLEPOINT",
|
||||
"The element has no clickable point",
|
||||
),
|
||||
UIA_E_PROXYASSEMBLYNOTLOADED => (
|
||||
"UIA_E_PROXYASSEMBLYNOTLOADED",
|
||||
"The proxy assembly could not be loaded",
|
||||
),
|
||||
UIA_E_NOTSUPPORTED => (
|
||||
"UIA_E_NOTSUPPORTED",
|
||||
"The requested operation is unsupported",
|
||||
),
|
||||
UIA_E_TIMEOUT => ("UIA_E_TIMEOUT", "The operation timed out"),
|
||||
UIA_E_INVALIDOPERATION => ("UIA_E_INVALIDOPERATION", "The operation is not valid"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(symbol)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod adapter;
|
||||
pub(crate) mod com_runtime;
|
||||
pub(crate) mod dpi;
|
||||
pub(crate) mod hresult;
|
||||
pub(crate) mod permissions;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) mod private_file;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ use agent_desktop_core::{AdapterError, Deadline, PermissionReport, PermissionSta
|
|||
|
||||
const ACCESSIBILITY_SUGGESTION: &str = "Run agent-desktop in an interactive desktop session as a user allowed to use the UI Automation COM runtime; restricted tokens and AppContainer processes are denied UIA access.";
|
||||
|
||||
const S_OK: i32 = 0;
|
||||
const E_ACCESSDENIED: i32 = 0x8007_0005_u32 as i32;
|
||||
pub(crate) use crate::system::hresult::com_hresult_detail;
|
||||
use crate::system::hresult::{E_ACCESSDENIED, S_OK};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
|
|
@ -130,14 +130,6 @@ pub(crate) fn uia_access_denied_error(hresult: i32) -> AdapterError {
|
|||
.with_platform_detail(com_hresult_detail(hresult))
|
||||
}
|
||||
|
||||
pub(crate) fn com_hresult_detail(hresult: i32) -> String {
|
||||
let code = hresult as u32;
|
||||
match hresult {
|
||||
E_ACCESSDENIED => format!("COM HRESULT 0x{code:08X} (E_ACCESSDENIED: Access is denied)"),
|
||||
_ => format!("COM HRESULT 0x{code:08X}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn screen_recording_report_state() -> PermissionState {
|
||||
map_capture_availability(imp::probe_capture_availability())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,21 @@ fn denial_platform_detail_matches_the_invariant_hresult_format() {
|
|||
#[test]
|
||||
fn unnamed_hresults_format_without_inventing_a_name() {
|
||||
assert_eq!(
|
||||
com_hresult_detail(0x8000_4005_u32 as i32),
|
||||
"COM HRESULT 0x80004005"
|
||||
com_hresult_detail(0x8007_0002_u32 as i32),
|
||||
"COM HRESULT 0x80070002"
|
||||
);
|
||||
assert!(crate::system::hresult::com_hresult_symbol(0x8007_0002_u32 as i32).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ui_automation_hresults_the_tree_path_raises_are_named() {
|
||||
assert_eq!(
|
||||
com_hresult_detail(0x8004_0201_u32 as i32),
|
||||
"COM HRESULT 0x80040201 (UIA_E_ELEMENTNOTAVAILABLE: The element is not available)"
|
||||
);
|
||||
assert_eq!(
|
||||
com_hresult_detail(0x8004_01F0_u32 as i32),
|
||||
"COM HRESULT 0x800401F0 (CO_E_NOTINITIALIZED: COM has not been initialized)"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
390
crates/windows/src/tree/automation.rs
Normal file
390
crates/windows/src/tree/automation.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
use agent_desktop_core::{AdapterError, Deadline, ErrorCode};
|
||||
|
||||
use crate::system::hresult::{
|
||||
CO_E_NOTINITIALIZED, E_ACCESSDENIED, E_INVALIDARG, E_POINTER, RPC_E_DISCONNECTED,
|
||||
RPC_E_SERVERFAULT, RPC_S_CALL_FAILED, RPC_S_SERVER_UNAVAILABLE, UIA_E_ELEMENTNOTAVAILABLE,
|
||||
UIA_E_ELEMENTNOTENABLED, UIA_E_INVALIDOPERATION, UIA_E_NOTSUPPORTED, UIA_E_TIMEOUT,
|
||||
com_hresult_detail,
|
||||
};
|
||||
use crate::system::permissions::ensure_budget;
|
||||
|
||||
pub const ERR_NONE: i32 = 0;
|
||||
pub const ERR_NOTFOUND: i32 = 1;
|
||||
pub const ERR_TIMEOUT: i32 = 2;
|
||||
pub const ERR_INACTIVE: i32 = 3;
|
||||
pub const ERR_TYPE: i32 = 4;
|
||||
pub const ERR_NULL_PTR: i32 = 5;
|
||||
pub const ERR_FORMAT: i32 = 6;
|
||||
pub const ERR_INVALID_OBJECT: i32 = 7;
|
||||
pub const ERR_ALREADY_RUNNING: i32 = 8;
|
||||
pub const ERR_INVALID_ARG: i32 = 9;
|
||||
|
||||
const COM_UNINITIALIZED_SUGGESTION: &str =
|
||||
"Join the calling thread to the COM multithreaded apartment before observing the desktop";
|
||||
|
||||
/// Longest this crate will wait to learn whether a window thread is pumping.
|
||||
///
|
||||
/// Bounded independently of the operation deadline: the probe exists to avoid
|
||||
/// an unbounded block, so spending the whole remaining budget on it would
|
||||
/// defeat its purpose.
|
||||
const PUMP_PROBE_CAP_MS: u64 = 2_000;
|
||||
|
||||
/// How long a UI Automation call may wait to reach a target's provider.
|
||||
///
|
||||
/// This is a backstop against an unbounded hang, not a latency budget - the
|
||||
/// operation `Deadline` bounds latency and is checked around every call. It is
|
||||
/// UI Automation's own documented default, kept explicit so the value is a
|
||||
/// decision rather than an inheritance.
|
||||
///
|
||||
/// The already-hung case does not wait this out. `root_from_hwnd` probes with
|
||||
/// `SendMessageTimeoutW` first and fails in `PUMP_PROBE_CAP_MS`; this bound
|
||||
/// only catches a target that stops dispatching after answering that probe.
|
||||
pub const CONNECTION_TIMEOUT_MS: u32 = 2_000;
|
||||
|
||||
/// How long one UI Automation transaction may take end to end.
|
||||
pub const TRANSACTION_TIMEOUT_MS: u32 = 20_000;
|
||||
|
||||
/// Reports a window whose thread is not dispatching messages.
|
||||
///
|
||||
/// Distinct from a window that does not exist: the handle is valid, the
|
||||
/// provider is simply unreachable, and retrying later can succeed.
|
||||
pub fn unresponsive_window_error() -> AdapterError {
|
||||
AdapterError::new(
|
||||
ErrorCode::AppUnresponsive,
|
||||
"The window's thread is not dispatching messages",
|
||||
)
|
||||
.with_suggestion("Wait for the application to become responsive, then retry")
|
||||
.with_platform_detail("WM_GETOBJECT would block: the window thread did not answer WM_NULL")
|
||||
}
|
||||
|
||||
/// One UI Automation failure, already split on the discriminator the crate's
|
||||
/// `Error` type mixes into a single `i32`.
|
||||
///
|
||||
/// `uiautomation::Error` carries its own non-negative sentinels (`ERR_NONE`,
|
||||
/// `ERR_NOTFOUND`, …) in the same field as a real HRESULT, so `code()` alone
|
||||
/// is ambiguous. `result()` is the only honest branch: `Some` for an HRESULT,
|
||||
/// `None` for a crate sentinel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UiaFailure {
|
||||
Hresult(i32),
|
||||
Sentinel(i32),
|
||||
}
|
||||
|
||||
impl UiaFailure {
|
||||
/// Reports whether this failure is the benign end-of-list signal a child
|
||||
/// enumeration must not mistake for a fault.
|
||||
///
|
||||
/// `windows-core` returns `Error::empty()` for a null interface out-param,
|
||||
/// whose `code()` is `HRESULT(0)`, which `uiautomation` stores verbatim.
|
||||
/// The runtime pair is measured by the CI capability probe rather than
|
||||
/// inferred from that chain.
|
||||
pub fn is_exhaustion(self) -> bool {
|
||||
matches!(self, UiaFailure::Sentinel(ERR_NONE))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a structured adapter error from a UI Automation failure.
|
||||
///
|
||||
/// `context` is a caller-supplied, shape-only phrase. Nothing observed from
|
||||
/// the target — a property value, a `Name`, a `ClassName`, a window title, or
|
||||
/// a `ProviderDescription` — may reach this function, because
|
||||
/// `ref_action.rs` clones adapter error text into session trace segments.
|
||||
pub fn uia_failure_error(failure: UiaFailure, context: &str) -> AdapterError {
|
||||
match failure {
|
||||
UiaFailure::Hresult(hresult) => {
|
||||
let (code, suggestion) = hresult_disposition(hresult);
|
||||
let error = AdapterError::new(code, format!("UI Automation could not {context}"))
|
||||
.with_platform_detail(com_hresult_detail(hresult));
|
||||
match suggestion {
|
||||
Some(hint) => error.with_suggestion(hint),
|
||||
None => error,
|
||||
}
|
||||
}
|
||||
UiaFailure::Sentinel(sentinel) => AdapterError::new(
|
||||
sentinel_disposition(sentinel),
|
||||
format!("UI Automation could not {context}"),
|
||||
)
|
||||
.with_platform_detail(format!("UI Automation client status {sentinel}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn hresult_disposition(hresult: i32) -> (ErrorCode, Option<&'static str>) {
|
||||
match hresult {
|
||||
E_ACCESSDENIED => (ErrorCode::PermDenied, None),
|
||||
CO_E_NOTINITIALIZED => (ErrorCode::Internal, Some(COM_UNINITIALIZED_SUGGESTION)),
|
||||
E_INVALIDARG | E_POINTER => (ErrorCode::InvalidArgs, None),
|
||||
UIA_E_ELEMENTNOTAVAILABLE => (ErrorCode::StaleRef, None),
|
||||
UIA_E_ELEMENTNOTENABLED => (ErrorCode::ActionFailed, None),
|
||||
UIA_E_NOTSUPPORTED => (ErrorCode::ActionNotSupported, None),
|
||||
UIA_E_TIMEOUT => (ErrorCode::Timeout, None),
|
||||
UIA_E_INVALIDOPERATION => (ErrorCode::ActionFailed, None),
|
||||
RPC_E_DISCONNECTED | RPC_E_SERVERFAULT | RPC_S_SERVER_UNAVAILABLE | RPC_S_CALL_FAILED => {
|
||||
(ErrorCode::AppUnresponsive, None)
|
||||
}
|
||||
_ => (ErrorCode::Internal, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn sentinel_disposition(sentinel: i32) -> ErrorCode {
|
||||
match sentinel {
|
||||
ERR_NOTFOUND | ERR_NULL_PTR => ErrorCode::ElementNotFound,
|
||||
ERR_TIMEOUT => ErrorCode::Timeout,
|
||||
ERR_INACTIVE => ErrorCode::AppUnresponsive,
|
||||
ERR_INVALID_OBJECT => ErrorCode::StaleRef,
|
||||
ERR_INVALID_ARG => ErrorCode::InvalidArgs,
|
||||
ERR_NONE | ERR_TYPE | ERR_FORMAT | ERR_ALREADY_RUNNING => ErrorCode::Internal,
|
||||
_ => ErrorCode::Internal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites a root-resolution failure so a window that no longer exists is
|
||||
/// reported as a missing window rather than a stale element.
|
||||
pub fn root_resolution_error(failure: UiaFailure) -> AdapterError {
|
||||
let error = uia_failure_error(failure, "resolve a window root");
|
||||
match failure {
|
||||
UiaFailure::Hresult(UIA_E_ELEMENTNOTAVAILABLE) | UiaFailure::Sentinel(ERR_NOTFOUND) => {
|
||||
AdapterError::new(
|
||||
ErrorCode::WindowNotFound,
|
||||
"UI Automation could not resolve a window root",
|
||||
)
|
||||
.with_platform_detail(error.platform_detail.unwrap_or_default())
|
||||
.with_suggestion("List windows again and retry with a current window handle")
|
||||
}
|
||||
_ => error,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
use super::{UiaFailure, root_resolution_error, uia_failure_error};
|
||||
use crate::tree::element::UIAElement;
|
||||
use agent_desktop_core::{AdapterError, Deadline};
|
||||
use std::cell::OnceCell;
|
||||
use uiautomation::{Error as UiaError, UIAutomation, types::Handle};
|
||||
use windows::Win32::System::Com::{CLSCTX_ALL, CoCreateInstance};
|
||||
use windows::Win32::UI::Accessibility::{CUIAutomation8, IUIAutomation, IUIAutomation2};
|
||||
|
||||
thread_local! {
|
||||
static CLIENT: OnceCell<UIAutomation> = const { OnceCell::new() };
|
||||
}
|
||||
|
||||
/// Serializes the first UI Automation touch across threads.
|
||||
///
|
||||
/// UI Automation's client core initializes lazily on first use and that
|
||||
/// initialization is **not re-entrant**: when several threads first reach
|
||||
/// it at once, all but one abort with `E_FAIL` and the message "Re-Entrant
|
||||
/// CheckInit() call, aborting". Measured on this box as three concurrent
|
||||
/// `get_root_element` calls, of which two failed instantly - not a
|
||||
/// timeout, an outright refusal.
|
||||
///
|
||||
/// Creating the client is not enough to trigger it; the first *call* is.
|
||||
/// So the lock is held across a warm-up call, and released once the
|
||||
/// process-wide core is up. Per-thread clients are safe from then on.
|
||||
static FIRST_TOUCH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Splits a crate error onto the `result()` discriminator.
|
||||
pub fn failure_of(error: &UiaError) -> UiaFailure {
|
||||
match error.result() {
|
||||
Some(hresult) => UiaFailure::Hresult(hresult.0),
|
||||
None => UiaFailure::Sentinel(error.code()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uia_error(error: &UiaError, context: &str) -> AdapterError {
|
||||
uia_failure_error(failure_of(error), context)
|
||||
}
|
||||
|
||||
/// Hands out this thread's UI Automation client.
|
||||
///
|
||||
/// Constructed by direct `CoCreateInstance`, never through
|
||||
/// `UIAutomation::new()`. `new()` would call `CoInitializeEx` itself: on a
|
||||
/// thread already in the MTA that returns `S_FALSE` and permanently leaks
|
||||
/// one initialization count, and on any STA host thread it fails outright
|
||||
/// with `RPC_E_CHANGED_MODE`. Sub-phase 2.1's bootstrap owns the
|
||||
/// apartment, so this accessor asserts the precondition instead of
|
||||
/// establishing it. See `create_bounded_client` for which CLSID, and why.
|
||||
pub fn automation_client() -> Result<UIAutomation, AdapterError> {
|
||||
CLIENT.with(|cell| {
|
||||
if let Some(client) = cell.get() {
|
||||
return Ok(client.clone());
|
||||
}
|
||||
let client = create_serialized_client()?;
|
||||
let _ = cell.set(client.clone());
|
||||
Ok(client)
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds this thread's client without racing UI Automation's own lazy
|
||||
/// initialization against another thread's.
|
||||
fn create_serialized_client() -> Result<UIAutomation, AdapterError> {
|
||||
let guard = FIRST_TOUCH.lock();
|
||||
let client = create_bounded_client()?;
|
||||
let _ = client.get_root_element();
|
||||
drop(guard);
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Builds a client whose calls are bounded, and fails rather than hand
|
||||
/// back one whose calls are not.
|
||||
///
|
||||
/// `UIAutomation::new_direct()` is `CoCreateInstance(&CUIAutomation, ...)`,
|
||||
/// and that object does not support `IUIAutomation2`, so its calls carry no
|
||||
/// timeout at all: measured against a window that stopped dispatching,
|
||||
/// `ElementFromHandle` did not return inside a 30 s watchdog.
|
||||
/// `CUIAutomation8` exposes `SetConnectionTimeout`, and the same call then
|
||||
/// returns `UIA_E_TIMEOUT` inside the bound.
|
||||
///
|
||||
/// This keeps every property `new_direct()` was chosen for - it is the same
|
||||
/// direct `CoCreateInstance`, it never calls `CoInitializeEx`, so it works
|
||||
/// inside an STA host and leaks no initialization count in a long-lived
|
||||
/// process. Only the CLSID differs.
|
||||
///
|
||||
/// There is deliberately **no fallback to the unbounded client**. A
|
||||
/// fallback would silently trade the hang guarantee for availability on a
|
||||
/// platform that cannot occur: `CUIAutomation8` has shipped since
|
||||
/// Windows 8, the product's floor is Windows 10 1809, and it is measured
|
||||
/// present on both build 17763 and the Server 2025 CI image. A client
|
||||
/// whose calls cannot be bounded is one this crate should refuse, not one
|
||||
/// it should quietly accept.
|
||||
fn create_bounded_client() -> Result<UIAutomation, AdapterError> {
|
||||
let client: IUIAutomation2 = unsafe { CoCreateInstance(&CUIAutomation8, None, CLSCTX_ALL) }
|
||||
.map_err(|error| unbounded_client_error(error.code().0))?;
|
||||
unsafe {
|
||||
client
|
||||
.SetConnectionTimeout(super::CONNECTION_TIMEOUT_MS)
|
||||
.map_err(|error| unbounded_client_error(error.code().0))?;
|
||||
client
|
||||
.SetTransactionTimeout(super::TRANSACTION_TIMEOUT_MS)
|
||||
.map_err(|error| unbounded_client_error(error.code().0))?;
|
||||
}
|
||||
let automation: IUIAutomation = client.into();
|
||||
Ok(UIAutomation::from(automation))
|
||||
}
|
||||
|
||||
fn unbounded_client_error(hresult: i32) -> AdapterError {
|
||||
uia_failure_error(
|
||||
UiaFailure::Hresult(hresult),
|
||||
"create a UI Automation client whose calls are bounded",
|
||||
)
|
||||
.with_suggestion(
|
||||
"This build does not provide CUIAutomation8; observation is refused rather than run against a client that cannot time out",
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolves a top-level window handle to its UI Automation root element.
|
||||
///
|
||||
/// `ElementFromHandle` sends `WM_GETOBJECT` to the target's window thread,
|
||||
/// and a cross-thread `SendMessage` has no timeout of its own. A `Deadline`
|
||||
/// checked around the call therefore **cannot** interrupt it: against a
|
||||
/// target that has stopped pumping, the call blocks and the deadline is
|
||||
/// only observed once it returns.
|
||||
///
|
||||
/// So the target is asked first whether it is pumping at all, with
|
||||
/// `SendMessageTimeoutW(WM_NULL, SMTO_ABORTIFHUNG)` - the documented way to
|
||||
/// put a bound on exactly this question. A target that is already hung
|
||||
/// becomes a structured `APP_UNRESPONSIVE` instead of an indefinite block.
|
||||
///
|
||||
/// The probe is the fast, precise answer, not the safety net. A target
|
||||
/// that answers it and then stops dispatching cannot be caught by any
|
||||
/// preflight, so the bound that actually holds is the client's own
|
||||
/// `ConnectionTimeout` (see `create_bounded_client`): that call returns
|
||||
/// `UIA_E_TIMEOUT` rather than blocking. The probe exists because it turns
|
||||
/// an already-hung target into a clearer error, sooner, than waiting the
|
||||
/// connection timeout out.
|
||||
pub fn root_from_hwnd(hwnd: isize, deadline: Deadline) -> Result<UIAElement, AdapterError> {
|
||||
crate::system::permissions::ensure_budget(deadline)?;
|
||||
let client = automation_client()?;
|
||||
if !window_exists(hwnd) {
|
||||
return Err(root_resolution_error(UiaFailure::Sentinel(
|
||||
super::ERR_NOTFOUND,
|
||||
)));
|
||||
}
|
||||
let probe_ms = deadline.remaining_ms().min(super::PUMP_PROBE_CAP_MS);
|
||||
if !window_is_pumping(hwnd, probe_ms) {
|
||||
return Err(super::unresponsive_window_error());
|
||||
}
|
||||
let element = client
|
||||
.element_from_handle(Handle::from(hwnd))
|
||||
.map_err(|error| root_resolution_error(failure_of(&error)))?;
|
||||
crate::system::permissions::ensure_budget(deadline)?;
|
||||
Ok(UIAElement::from(element))
|
||||
}
|
||||
|
||||
/// Reports whether a handle still addresses a live window.
|
||||
///
|
||||
/// Asked before the pump probe so the two failures stay distinct: a handle
|
||||
/// that addresses nothing is a missing window, which is the mapping A14-5
|
||||
/// measured, while a live window that will not answer is an unresponsive
|
||||
/// application. Collapsing them would make a destroyed window look like a
|
||||
/// hung one and send a caller into a pointless retry.
|
||||
pub fn window_exists(hwnd: isize) -> bool {
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::IsWindow;
|
||||
hwnd != 0 && unsafe { IsWindow(hwnd as *mut std::ffi::c_void) } != 0
|
||||
}
|
||||
|
||||
/// Asks whether a window's thread is dispatching messages, without
|
||||
/// blocking on it.
|
||||
///
|
||||
/// `WM_NULL` is the no-op message every window proc handles, and
|
||||
/// `SMTO_ABORTIFHUNG` makes the call return immediately when the target is
|
||||
/// already known to be hung rather than waiting out the timeout.
|
||||
pub fn window_is_pumping(hwnd: isize, timeout_ms: u64) -> bool {
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_NULL,
|
||||
};
|
||||
let mut answer: usize = 0;
|
||||
let sent = unsafe {
|
||||
SendMessageTimeoutW(
|
||||
hwnd as *mut std::ffi::c_void,
|
||||
WM_NULL,
|
||||
0,
|
||||
0,
|
||||
SMTO_ABORTIFHUNG,
|
||||
u32::try_from(timeout_ms.max(1)).unwrap_or(u32::MAX),
|
||||
&mut answer,
|
||||
)
|
||||
};
|
||||
sent != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod imp {
|
||||
use super::{UiaFailure, root_resolution_error};
|
||||
use crate::tree::element::{CannedElement, UIAElement};
|
||||
use agent_desktop_core::{AdapterError, Deadline};
|
||||
|
||||
/// Canned arm so the classifier tests, and every module that calls the
|
||||
/// resolver, compile and run on a non-Windows lane.
|
||||
pub fn root_from_hwnd(hwnd: isize, deadline: Deadline) -> Result<UIAElement, AdapterError> {
|
||||
crate::system::permissions::ensure_budget(deadline)?;
|
||||
if hwnd == 0 {
|
||||
return Err(root_resolution_error(UiaFailure::Sentinel(
|
||||
super::ERR_NOTFOUND,
|
||||
)));
|
||||
}
|
||||
Ok(UIAElement::from(CannedElement))
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::root_from_hwnd;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use imp::{automation_client, failure_of, uia_error, window_exists, window_is_pumping};
|
||||
|
||||
/// Rejects a window handle that cannot address a window before it reaches the
|
||||
/// COM layer, so a null handle is an argument error rather than a COM failure.
|
||||
pub fn validate_window_handle(hwnd: isize, deadline: Deadline) -> Result<(), AdapterError> {
|
||||
ensure_budget(deadline)?;
|
||||
if hwnd == 0 {
|
||||
return Err(
|
||||
AdapterError::new(ErrorCode::InvalidArgs, "Window handle is empty")
|
||||
.with_suggestion("Pass a window handle obtained from a window listing"),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "automation_tests.rs"]
|
||||
mod tests;
|
||||
379
crates/windows/src/tree/automation_tests.rs
Normal file
379
crates/windows/src/tree/automation_tests.rs
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
use super::*;
|
||||
|
||||
const E_ACCESSDENIED_HRESULT: i32 = 0x8007_0005_u32 as i32;
|
||||
const UIA_E_ELEMENTNOTAVAILABLE_HRESULT: i32 = 0x8004_0201_u32 as i32;
|
||||
const RPC_S_SERVER_UNAVAILABLE_HRESULT: i32 = 0x8007_06BA_u32 as i32;
|
||||
const UNRECOGNISED_SENTINEL: i32 = 4242;
|
||||
const LEAK_MARKER: &str = "zzmarkerzz-secret-window-title";
|
||||
|
||||
#[test]
|
||||
fn a_negative_hresult_formats_with_its_symbolic_name() {
|
||||
let error = uia_failure_error(
|
||||
UiaFailure::Hresult(E_ACCESSDENIED_HRESULT),
|
||||
"read a property",
|
||||
);
|
||||
|
||||
assert_eq!(error.code, ErrorCode::PermDenied);
|
||||
assert_eq!(
|
||||
error.platform_detail.as_deref(),
|
||||
Some("COM HRESULT 0x80070005 (E_ACCESSDENIED: Access is denied)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crate_sentinel_never_prints_a_fabricated_hresult() {
|
||||
let error = uia_failure_error(UiaFailure::Sentinel(ERR_TIMEOUT), "read a property");
|
||||
|
||||
assert_eq!(error.code, ErrorCode::Timeout);
|
||||
let detail = error.platform_detail.expect("a sentinel carries detail");
|
||||
assert!(!detail.contains("0x"), "sentinel detail was {detail}");
|
||||
assert!(!error.message.contains("0x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrecognised_sentinel_maps_to_internal_rather_than_a_guess() {
|
||||
let error = uia_failure_error(
|
||||
UiaFailure::Sentinel(UNRECOGNISED_SENTINEL),
|
||||
"walk a subtree",
|
||||
);
|
||||
|
||||
assert_eq!(error.code, ErrorCode::Internal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_named_sentinel_maps_without_a_catch_all_guess() {
|
||||
assert_eq!(
|
||||
sentinel_disposition(ERR_NOTFOUND),
|
||||
ErrorCode::ElementNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
sentinel_disposition(ERR_NULL_PTR),
|
||||
ErrorCode::ElementNotFound
|
||||
);
|
||||
assert_eq!(sentinel_disposition(ERR_TIMEOUT), ErrorCode::Timeout);
|
||||
assert_eq!(
|
||||
sentinel_disposition(ERR_INACTIVE),
|
||||
ErrorCode::AppUnresponsive
|
||||
);
|
||||
assert_eq!(
|
||||
sentinel_disposition(ERR_INVALID_OBJECT),
|
||||
ErrorCode::StaleRef
|
||||
);
|
||||
assert_eq!(
|
||||
sentinel_disposition(ERR_INVALID_ARG),
|
||||
ErrorCode::InvalidArgs
|
||||
);
|
||||
for internal in [ERR_NONE, ERR_TYPE, ERR_FORMAT, ERR_ALREADY_RUNNING] {
|
||||
assert_eq!(sentinel_disposition(internal), ErrorCode::Internal);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disconnected_provider_reports_an_unresponsive_app() {
|
||||
let error = uia_failure_error(
|
||||
UiaFailure::Hresult(RPC_S_SERVER_UNAVAILABLE_HRESULT),
|
||||
"enumerate children",
|
||||
);
|
||||
|
||||
assert_eq!(error.code, ErrorCode::AppUnresponsive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_empty_sentinel_counts_as_exhaustion() {
|
||||
assert!(UiaFailure::Sentinel(ERR_NONE).is_exhaustion());
|
||||
assert!(!UiaFailure::Sentinel(ERR_NOTFOUND).is_exhaustion());
|
||||
assert!(!UiaFailure::Hresult(0).is_exhaustion());
|
||||
assert!(!UiaFailure::Hresult(UIA_E_ELEMENTNOTAVAILABLE_HRESULT).is_exhaustion());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_root_is_a_missing_window_not_a_stale_element() {
|
||||
let by_hresult = root_resolution_error(UiaFailure::Hresult(UIA_E_ELEMENTNOTAVAILABLE_HRESULT));
|
||||
let by_sentinel = root_resolution_error(UiaFailure::Sentinel(ERR_NOTFOUND));
|
||||
|
||||
assert_eq!(by_hresult.code, ErrorCode::WindowNotFound);
|
||||
assert_eq!(by_sentinel.code, ErrorCode::WindowNotFound);
|
||||
assert!(
|
||||
by_hresult
|
||||
.platform_detail
|
||||
.is_some_and(|detail| detail.contains("UIA_E_ELEMENTNOTAVAILABLE"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_failure_that_is_not_a_missing_window_keeps_its_own_code() {
|
||||
let error = root_resolution_error(UiaFailure::Hresult(E_ACCESSDENIED_HRESULT));
|
||||
|
||||
assert_eq!(error.code, ErrorCode::PermDenied);
|
||||
}
|
||||
|
||||
/// KTD14: `ref_action.rs` clones adapter error text into session JSONL and
|
||||
/// into the trace HTML export, so a marker that reaches the caller through
|
||||
/// the context phrase must not reach the persisted error.
|
||||
#[test]
|
||||
fn a_classifier_error_carries_shape_and_never_target_content() {
|
||||
let error = uia_failure_error(
|
||||
UiaFailure::Hresult(E_ACCESSDENIED_HRESULT),
|
||||
"read a property",
|
||||
);
|
||||
let rendered = format!(
|
||||
"{}|{}|{}",
|
||||
error.message,
|
||||
error.platform_detail.clone().unwrap_or_default(),
|
||||
serde_json::to_string(&error.details).unwrap_or_default()
|
||||
);
|
||||
|
||||
assert!(!rendered.contains(LEAK_MARKER), "leaked: {rendered}");
|
||||
assert!(!rendered.to_ascii_lowercase().contains("zzmarkerzz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_window_handle_is_rejected_before_the_com_layer() {
|
||||
let deadline = Deadline::standard().expect("a standard deadline");
|
||||
|
||||
let Err(error) = validate_window_handle(0, deadline) else {
|
||||
panic!("an empty window handle must be rejected");
|
||||
};
|
||||
|
||||
assert_eq!(error.code, ErrorCode::InvalidArgs);
|
||||
assert!(validate_window_handle(1, deadline).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn the_canned_resolver_reports_a_missing_window_for_an_empty_handle() {
|
||||
let deadline = Deadline::standard().expect("a standard deadline");
|
||||
|
||||
let Err(error) = root_from_hwnd(0, deadline) else {
|
||||
panic!("the canned arm must reject an empty handle");
|
||||
};
|
||||
|
||||
assert_eq!(error.code, ErrorCode::WindowNotFound);
|
||||
assert!(root_from_hwnd(1, deadline).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_only {
|
||||
use super::*;
|
||||
use crate::tree::fixture::bootstrap;
|
||||
use uiautomation::Error as UiaError;
|
||||
|
||||
#[test]
|
||||
fn the_client_is_reusable_within_a_thread() {
|
||||
bootstrap();
|
||||
|
||||
assert!(automation_client().is_ok());
|
||||
assert!(automation_client().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hresult_error_splits_onto_the_result_branch() {
|
||||
let error = UiaError::from(windows::core::HRESULT(E_ACCESSDENIED_HRESULT));
|
||||
|
||||
assert_eq!(
|
||||
failure_of(&error),
|
||||
UiaFailure::Hresult(E_ACCESSDENIED_HRESULT)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentinel_error_splits_onto_the_sentinel_branch() {
|
||||
let error = UiaError::new(ERR_TIMEOUT, "");
|
||||
|
||||
assert_eq!(failure_of(&error), UiaFailure::Sentinel(ERR_TIMEOUT));
|
||||
}
|
||||
|
||||
/// The runtime end-of-list pair the walker depends on, asserted against
|
||||
/// the real crate rather than against a reading of it.
|
||||
#[test]
|
||||
fn a_null_interface_out_param_reports_the_exhaustion_sentinel() {
|
||||
let error = UiaError::from(windows::core::Error::empty());
|
||||
|
||||
assert!(failure_of(&error).is_exhaustion());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_destroyed_window_handle_reports_a_missing_window() {
|
||||
bootstrap();
|
||||
let deadline = Deadline::standard().expect("a standard deadline");
|
||||
|
||||
let Err(error) = root_from_hwnd(isize::MAX, deadline) else {
|
||||
panic!("a handle that addresses no window must not resolve");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
error.code,
|
||||
ErrorCode::WindowNotFound | ErrorCode::InvalidArgs | ErrorCode::ElementNotFound
|
||||
));
|
||||
}
|
||||
|
||||
/// Greptile raised this as a P1 and it was right: two deadline checks
|
||||
/// cannot interrupt the blocking `SendMessage` between them, so a target
|
||||
/// that has stopped pumping would block the caller indefinitely.
|
||||
///
|
||||
/// The 2.2 plan records the condition as unverified because "the fixture
|
||||
/// cannot produce it". It can - `CreateWindowExW` dispatches `WM_CREATE`
|
||||
/// inline, so a thread can own a live, visible window and then never
|
||||
/// dispatch again. Against that window the resolver must return a
|
||||
/// structured `APP_UNRESPONSIVE` promptly, not hang; the watchdog makes a
|
||||
/// regression fail loudly rather than stall the lane.
|
||||
#[test]
|
||||
fn a_window_that_stopped_pumping_is_reported_rather_than_blocked_on() {
|
||||
bootstrap();
|
||||
let stalled = crate::tree::fixture::StalledFixture::create()
|
||||
.expect("a non-pumping window is created");
|
||||
let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let watchdog = done.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
assert!(
|
||||
watchdog.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"root resolution blocked on a window that stopped pumping"
|
||||
);
|
||||
});
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let outcome = root_from_hwnd(
|
||||
stalled.handle(),
|
||||
Deadline::after(5_000).expect("a bounded deadline"),
|
||||
);
|
||||
done.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
let Err(error) = outcome else {
|
||||
panic!("a window that is not dispatching must not resolve");
|
||||
};
|
||||
assert_eq!(error.code, ErrorCode::AppUnresponsive);
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_secs(20),
|
||||
"the probe must bound the wait, took {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// Why the client is built from `CUIAutomation8` rather than the CLSID
|
||||
/// `UIAutomation::new_direct()` uses: the crate's object does not support
|
||||
/// `IUIAutomation2`, so its calls carry no timeout at all. That is the
|
||||
/// whole reason this crate does not use it, and the reason there is no
|
||||
/// fallback to it.
|
||||
#[test]
|
||||
fn the_crates_own_client_carries_no_timeout_which_is_why_it_is_not_used() {
|
||||
use windows::Win32::UI::Accessibility::{IUIAutomation, IUIAutomation2};
|
||||
use windows::core::Interface;
|
||||
bootstrap();
|
||||
let crate_client = uiautomation::UIAutomation::new_direct().expect("the crate's client");
|
||||
let raw: IUIAutomation = crate_client.as_ref().clone();
|
||||
|
||||
assert!(raw.cast::<IUIAutomation2>().is_err());
|
||||
}
|
||||
|
||||
/// The client this crate hands out is the bounded one - always, with no
|
||||
/// unbounded fallback. A client whose calls cannot time out is refused
|
||||
/// rather than quietly accepted, so this assertion holds on every build
|
||||
/// the product supports or client creation fails outright.
|
||||
#[test]
|
||||
fn the_shipped_client_exposes_the_timeouts_it_sets() {
|
||||
use windows::Win32::UI::Accessibility::{IUIAutomation, IUIAutomation2};
|
||||
use windows::core::Interface;
|
||||
bootstrap();
|
||||
let client = automation_client().expect("a client");
|
||||
let raw: IUIAutomation = client.as_ref().clone();
|
||||
|
||||
let bounded: IUIAutomation2 = raw
|
||||
.cast()
|
||||
.expect("the shipped client must expose IUIAutomation2");
|
||||
assert_eq!(
|
||||
unsafe { bounded.ConnectionTimeout() }.expect("a connection timeout is set"),
|
||||
crate::tree::automation::CONNECTION_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
/// The race Greptile named: a target that answers the pump probe and then
|
||||
/// stops dispatching. The probe cannot help there, so the bound has to be
|
||||
/// on the call itself.
|
||||
///
|
||||
/// Asserted by skipping the probe entirely and calling the client
|
||||
/// directly, which is the worst case the resolver can face. The client's
|
||||
/// connection timeout returns `UIA_E_TIMEOUT` instead of blocking.
|
||||
#[test]
|
||||
fn the_client_bounds_a_call_the_pump_probe_cannot_catch() {
|
||||
bootstrap();
|
||||
let stalled = crate::tree::fixture::StalledFixture::create()
|
||||
.expect("a non-pumping window is created");
|
||||
let client = automation_client().expect("a client");
|
||||
let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let watchdog = done.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
assert!(
|
||||
watchdog.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"the client did not bound a call against a non-pumping target"
|
||||
);
|
||||
});
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let outcome =
|
||||
client.element_from_handle(uiautomation::types::Handle::from(stalled.handle()));
|
||||
done.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
assert!(outcome.is_err(), "a stalled target must not resolve");
|
||||
let bound = std::time::Duration::from_millis(u64::from(
|
||||
crate::tree::automation::CONNECTION_TIMEOUT_MS,
|
||||
)) * 2;
|
||||
assert!(
|
||||
started.elapsed() < bound,
|
||||
"the call must be bounded by the client's connection timeout, took {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// UI Automation's client core initializes lazily and not re-entrantly:
|
||||
/// concurrent first touches made all but one thread fail instantly with
|
||||
/// E_FAIL and "Re-Entrant CheckInit() call, aborting". The accessor
|
||||
/// serializes that first touch, so every thread gets a working client.
|
||||
#[test]
|
||||
fn concurrent_first_touches_do_not_race_uia_lazy_initialization() {
|
||||
bootstrap();
|
||||
let readers: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
std::thread::spawn(|| {
|
||||
bootstrap();
|
||||
let client = automation_client().expect("a client per thread");
|
||||
client.get_root_element().is_ok()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for reader in readers {
|
||||
assert!(
|
||||
reader.join().expect("the reader thread completes"),
|
||||
"a healthy desktop-root read was refused by the connection bound"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pumping_window_passes_the_probe() {
|
||||
bootstrap();
|
||||
let fixture =
|
||||
crate::tree::fixture::LocalFixture::create().expect("a pumping window is created");
|
||||
|
||||
assert!(crate::tree::automation::window_is_pumping(
|
||||
fixture.handle(),
|
||||
2_000
|
||||
));
|
||||
assert!(!crate::tree::automation::window_is_pumping(0, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_uia_error_never_carries_the_crate_message_verbatim() {
|
||||
let error = uia_error(&UiaError::new(ERR_TIMEOUT, LEAK_MARKER), "read a property");
|
||||
|
||||
assert!(!error.message.contains(LEAK_MARKER));
|
||||
assert!(
|
||||
!error
|
||||
.platform_detail
|
||||
.unwrap_or_default()
|
||||
.contains(LEAK_MARKER)
|
||||
);
|
||||
}
|
||||
}
|
||||
140
crates/windows/src/tree/cache.rs
Normal file
140
crates/windows/src/tree/cache.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use agent_desktop_core::LocatorField;
|
||||
|
||||
use super::properties::PropertyOutcome;
|
||||
|
||||
/// The module every client-side proxy provider is served from.
|
||||
///
|
||||
/// A tree whose providers all name this library is served **inside the client
|
||||
/// process**, so an uncached property read costs no cross-process RPC and a
|
||||
/// cache request is pure setup.
|
||||
const CLIENT_SIDE_PROVIDER_MODULE: &str = "uiautomationcore.dll";
|
||||
|
||||
/// Whether a subtree's reads are worth batching.
|
||||
///
|
||||
/// The decision is by provider class, never by node count. A node-count
|
||||
/// threshold is not implementable here: the cache request must be built
|
||||
/// *before* the walk, and the count is known only *after* it, so the arm would
|
||||
/// have no input at decision time. A6-2 also measured the only available
|
||||
/// candidate threshold on the managed stack, where A2-4 measured the same
|
||||
/// window as 3 nodes against 26 on the COM stack this sub-phase ships.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CachePolicy {
|
||||
/// Prefetch the walk property set with each element.
|
||||
Batch,
|
||||
/// Read live. A6-2 measured unconditional batching against an in-process
|
||||
/// client-side provider at 0.5763x overall and 0.436x on the find phase.
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl CachePolicy {
|
||||
pub fn batches(self) -> bool {
|
||||
matches!(self, CachePolicy::Batch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses the policy from a root-level `ProviderDescription` read.
|
||||
///
|
||||
/// A description that cannot be read leaves the policy at `Batch`: a wrong
|
||||
/// `Skip` costs a cross-process round trip per property on exactly the trees
|
||||
/// where batching pays, while a wrong `Batch` costs request setup on a tree
|
||||
/// whose reads were already local.
|
||||
pub fn policy_for(provider_description: &PropertyOutcome) -> CachePolicy {
|
||||
match provider_description.text() {
|
||||
LocatorField::Known(description) if served_only_by_client_side_providers(&description) => {
|
||||
CachePolicy::Skip
|
||||
}
|
||||
_ => CachePolicy::Batch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether every module named in a provider description is the
|
||||
/// client-side proxy library.
|
||||
///
|
||||
/// This is the mechanism rather than a number, so it travels: a description
|
||||
/// that names the target's own module, or any module other than the proxy
|
||||
/// library, describes a provider on the far side of a process boundary.
|
||||
fn served_only_by_client_side_providers(description: &str) -> bool {
|
||||
let lowered = description.to_ascii_lowercase();
|
||||
let mut named_a_module = false;
|
||||
for token in lowered.split(|character: char| {
|
||||
!(character.is_ascii_alphanumeric()
|
||||
|| character == '.'
|
||||
|| character == '_'
|
||||
|| character == '-')
|
||||
}) {
|
||||
if !(token.ends_with(".dll") || token.ends_with(".exe")) {
|
||||
continue;
|
||||
}
|
||||
named_a_module = true;
|
||||
if token != CLIENT_SIDE_PROVIDER_MODULE {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
named_a_module
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
use super::{CachePolicy, policy_for};
|
||||
use crate::tree::automation::{failure_of, uia_failure_error};
|
||||
use crate::tree::element::UIAElement;
|
||||
use crate::tree::properties::read_one;
|
||||
use crate::tree::property_ids::{TreeProperty, uia_property};
|
||||
use agent_desktop_core::AdapterError;
|
||||
use uiautomation::UIAutomation;
|
||||
use uiautomation::core::UICacheRequest;
|
||||
use uiautomation::types::{ElementMode, TreeScope};
|
||||
|
||||
/// Builds the request the walk uses.
|
||||
///
|
||||
/// `ElementMode::Full` always: with `AutomationElementMode_None` a client
|
||||
/// has no access to uncached properties or control patterns and cannot
|
||||
/// invoke an action, which would break every command from 2.6 onward.
|
||||
///
|
||||
/// The scope includes `TreeScope::Element`, because the scope is relative
|
||||
/// to the element the call returns and omitting `Element` silently fails
|
||||
/// to cache that element's own properties. `TreeScope` is a plain enum
|
||||
/// with no bitwise operators, so a combined scope has to come from a
|
||||
/// pre-combined variant or `try_from`; the walk needs only `Element`,
|
||||
/// since it retrieves each node itself.
|
||||
///
|
||||
/// Only the properties the walk will read are requested, `IsPassword`
|
||||
/// among them, so the secure-field gate has its input in the same batch
|
||||
/// as the properties it gates and costs no second round trip.
|
||||
pub fn build_walk_cache_request(client: &UIAutomation) -> Result<UICacheRequest, AdapterError> {
|
||||
let request = client
|
||||
.create_cache_request()
|
||||
.map_err(|error| uia_failure_error(failure_of(&error), "create a cache request"))?;
|
||||
request
|
||||
.set_element_mode(ElementMode::Full)
|
||||
.map_err(|error| uia_failure_error(failure_of(&error), "set the cache element mode"))?;
|
||||
request
|
||||
.set_tree_scope(TreeScope::Element)
|
||||
.map_err(|error| uia_failure_error(failure_of(&error), "set the cache tree scope"))?;
|
||||
for property in TreeProperty::WALK_SET {
|
||||
request
|
||||
.add_property(uia_property(property))
|
||||
.map_err(|error| {
|
||||
uia_failure_error(failure_of(&error), "add a property to the cache request")
|
||||
})?;
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Reads the root's `ProviderDescription` and picks the policy.
|
||||
///
|
||||
/// The description itself never leaves this function. It names modules,
|
||||
/// process ids and provider ids, and `ref_action.rs` clones adapter error
|
||||
/// text into session trace segments, so it must not reach an error or a
|
||||
/// log line.
|
||||
pub fn policy_for_root(root: &UIAElement) -> CachePolicy {
|
||||
policy_for(&read_one(root, TreeProperty::ProviderDescription))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use imp::{build_walk_cache_request, policy_for_root};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cache_tests.rs"]
|
||||
mod tests;
|
||||
247
crates/windows/src/tree/cache_tests.rs
Normal file
247
crates/windows/src/tree/cache_tests.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
use super::*;
|
||||
use crate::tree::properties::PropertyValue;
|
||||
|
||||
fn described(description: &str) -> PropertyOutcome {
|
||||
PropertyOutcome::Known(PropertyValue::Text(description.into()))
|
||||
}
|
||||
|
||||
/// A6-2's provider: classic Notepad served by `UIAutomationClientsideProviders`
|
||||
/// inside the client process, where an uncached read costs no RPC.
|
||||
#[test]
|
||||
fn an_in_process_client_side_provider_skips_the_cache() {
|
||||
let outcome = described(
|
||||
"[pid:0,providerId:0x0 Main:Nested [pid:0,providerId:0x0 Main(parent link):Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]",
|
||||
);
|
||||
|
||||
assert_eq!(policy_for(&outcome), CachePolicy::Skip);
|
||||
assert!(!policy_for(&outcome).batches());
|
||||
}
|
||||
|
||||
/// A6-1's provider: a real server-side provider on the far side of a process
|
||||
/// boundary, where the read pass was ~298x faster cached.
|
||||
#[test]
|
||||
fn a_cross_process_provider_engages_the_cache() {
|
||||
let outcome = described(
|
||||
"[pid:0,providerId:0x0 Main:Microsoft: Explorer Provider (unmanaged:explorerframe.dll); Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]",
|
||||
);
|
||||
|
||||
assert_eq!(policy_for(&outcome), CachePolicy::Batch);
|
||||
assert!(policy_for(&outcome).batches());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_managed_server_side_provider_engages_the_cache() {
|
||||
let outcome = described(
|
||||
"[pid:0,providerId:0x0 Main:Unidentified Provider (managed:PresentationFramework.dll)]",
|
||||
);
|
||||
|
||||
assert_eq!(policy_for(&outcome), CachePolicy::Batch);
|
||||
}
|
||||
|
||||
/// A provider that will not describe itself is treated as cross-process: a
|
||||
/// wrong Skip costs a round trip per property on exactly the trees where
|
||||
/// batching pays, a wrong Batch costs setup on a tree that was already cheap.
|
||||
#[test]
|
||||
fn an_unreadable_or_absent_description_engages_the_cache() {
|
||||
assert_eq!(policy_for(&PropertyOutcome::Unknown), CachePolicy::Batch);
|
||||
assert_eq!(policy_for(&PropertyOutcome::Absent), CachePolicy::Batch);
|
||||
assert_eq!(policy_for(&described("")), CachePolicy::Batch);
|
||||
assert_eq!(
|
||||
policy_for(&described("no module is named here at all")),
|
||||
CachePolicy::Batch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_module_test_is_case_insensitive_and_not_a_substring_match() {
|
||||
assert_eq!(
|
||||
policy_for(&described("Main:Proxy (unmanaged:UIAutomationCore.dll)")),
|
||||
CachePolicy::Skip
|
||||
);
|
||||
assert_eq!(
|
||||
policy_for(&described(
|
||||
"Main:Provider (unmanaged:notuiautomationcore.dll)"
|
||||
)),
|
||||
CachePolicy::Batch
|
||||
);
|
||||
}
|
||||
|
||||
/// KTD5's exclusion, asserted as an absence: a node-count arm cannot exist,
|
||||
/// because the count is unknown when the request is built.
|
||||
#[test]
|
||||
fn the_policy_branches_on_provider_class_and_never_on_size_or_speed() {
|
||||
let source = include_str!("cache.rs");
|
||||
let tests = include_str!("cache_tests.rs");
|
||||
|
||||
let forbidden = [
|
||||
concat!("node", "_count"),
|
||||
concat!("fast", "er"),
|
||||
concat!("speed", "up"),
|
||||
concat!("multi", "plier"),
|
||||
concat!("elap", "sed"),
|
||||
];
|
||||
for forbidden in forbidden {
|
||||
for line in source.lines().chain(tests.lines()) {
|
||||
let is_prose =
|
||||
line.trim_start().starts_with("///") || line.trim_start().starts_with("//!");
|
||||
assert!(
|
||||
is_prose || !line.contains(forbidden),
|
||||
"the cache module must not branch on or assert {forbidden}: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_only {
|
||||
use super::*;
|
||||
use crate::tree::element::UIAElement;
|
||||
use crate::tree::fixture::HostedFixture;
|
||||
use crate::tree::fixture::bootstrap;
|
||||
use crate::tree::properties::{read_cached, read_live};
|
||||
use crate::tree::property_ids::TreeProperty;
|
||||
use agent_desktop_core::Deadline;
|
||||
use uiautomation::types::TreeScope;
|
||||
|
||||
fn cached_children(handle: isize) -> Vec<UIAElement> {
|
||||
let root = crate::tree::automation::root_from_hwnd(
|
||||
handle,
|
||||
Deadline::standard().expect("a standard deadline"),
|
||||
)
|
||||
.expect("the fixture window resolves");
|
||||
let client = crate::tree::automation::automation_client().expect("a UIA client");
|
||||
let request = build_walk_cache_request(&client).expect("the cache request builds");
|
||||
let walker = client
|
||||
.get_raw_view_walker()
|
||||
.expect("the raw view walker is available");
|
||||
let mut children = Vec::new();
|
||||
if let Ok(mut current) = walker.get_first_child_build_cache(&root.0, &request) {
|
||||
loop {
|
||||
let next = walker.get_next_sibling_build_cache(¤t, &request);
|
||||
children.push(UIAElement::from(current));
|
||||
match next {
|
||||
Ok(sibling) => current = sibling,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
children
|
||||
}
|
||||
|
||||
/// `TreeScope` is a plain enum with no bitwise operators; whether a
|
||||
/// combined value round-trips through `try_from` is settled here rather
|
||||
/// than assumed, and the scope the code uses includes `Element`.
|
||||
#[test]
|
||||
fn the_tree_scope_the_request_uses_includes_element() {
|
||||
let combined = TreeScope::try_from(3);
|
||||
assert!(
|
||||
combined.is_ok() || combined.is_err(),
|
||||
"the behaviour is recorded either way"
|
||||
);
|
||||
assert_eq!(TreeScope::Element as i32, 1);
|
||||
assert_eq!(TreeScope::Subtree as i32, 7);
|
||||
if let Ok(scope) = combined {
|
||||
assert_eq!(scope as i32 & TreeScope::Element as i32, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cached_read_matches_an_uncached_read_of_the_same_property() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let children = cached_children(fixture.handle());
|
||||
assert!(!children.is_empty(), "the fixture exposes child controls");
|
||||
|
||||
for child in &children {
|
||||
let (cached, _) = read_cached(child);
|
||||
let (live, _) = read_live(child);
|
||||
for property in TreeProperty::WALK_SET {
|
||||
assert_eq!(
|
||||
cached.get(property),
|
||||
live.get(property),
|
||||
"{} disagreed between the cache and a live read",
|
||||
property.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ElementMode::Full` keeps the element bound to the live UI, so a live
|
||||
/// getter still works after a cached read. `ElementMode::None` would
|
||||
/// break every action command from 2.6 onward.
|
||||
#[test]
|
||||
fn live_getters_still_work_after_a_cached_read() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let children = cached_children(fixture.handle());
|
||||
let child = children
|
||||
.first()
|
||||
.expect("the fixture exposes child controls");
|
||||
|
||||
let _ = read_cached(child);
|
||||
|
||||
assert!(child.0.get_control_type().is_ok());
|
||||
assert!(child.0.get_bounding_rectangle().is_ok());
|
||||
}
|
||||
|
||||
/// A property absent from the request is a programming error, never a
|
||||
/// silent live fetch and never `Absent`.
|
||||
#[test]
|
||||
fn a_property_outside_the_request_reads_unknown_with_a_structured_error() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let root = crate::tree::automation::root_from_hwnd(
|
||||
fixture.handle(),
|
||||
Deadline::standard().expect("a standard deadline"),
|
||||
)
|
||||
.expect("the fixture window resolves");
|
||||
let client = crate::tree::automation::automation_client().expect("a UIA client");
|
||||
let request = client
|
||||
.create_cache_request()
|
||||
.expect("an empty cache request");
|
||||
request
|
||||
.set_element_mode(uiautomation::types::ElementMode::Full)
|
||||
.expect("the element mode is settable");
|
||||
let sparse = root
|
||||
.0
|
||||
.build_updated_cache(&request)
|
||||
.map(UIAElement::from)
|
||||
.expect("an element with an empty cache");
|
||||
|
||||
let (properties, errors) = read_cached(&sparse);
|
||||
|
||||
assert!(
|
||||
!errors.is_empty(),
|
||||
"an unrequested property must surface a structured error"
|
||||
);
|
||||
assert_eq!(
|
||||
properties.get(TreeProperty::ClassName),
|
||||
PropertyOutcome::Unknown
|
||||
);
|
||||
assert_ne!(
|
||||
properties.get(TreeProperty::ClassName),
|
||||
PropertyOutcome::Absent
|
||||
);
|
||||
}
|
||||
|
||||
/// The policy is asserted against a real out-of-process provider, because
|
||||
/// an in-process one is exactly the branch the policy says to skip.
|
||||
#[test]
|
||||
fn the_policy_reads_a_real_provider_without_carrying_its_description_anywhere() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let root = crate::tree::automation::root_from_hwnd(
|
||||
fixture.handle(),
|
||||
Deadline::standard().expect("a standard deadline"),
|
||||
)
|
||||
.expect("the fixture window resolves");
|
||||
|
||||
let policy = policy_for_root(&root);
|
||||
|
||||
assert!(matches!(policy, CachePolicy::Batch | CachePolicy::Skip));
|
||||
assert!(
|
||||
!format!("{policy:?}").contains("uiautomationcore"),
|
||||
"the policy must not carry the provider description"
|
||||
);
|
||||
}
|
||||
}
|
||||
20
crates/windows/src/tree/captures.rs
Normal file
20
crates/windows/src/tree/captures.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Where sub-phase 2.2's committed COM evidence lives, and the normalisation
|
||||
//! rules it is held to.
|
||||
//!
|
||||
//! The captures under `docs/plans/2026-07-27-002-captures/` are produced by
|
||||
//! `crates/windows/examples/uia_tree_dump.rs` on a developer machine, in the
|
||||
//! UIA3 COM stack the adapter ships. Sub-phase 2.0's dumps were taken on the
|
||||
//! managed stack, which A2-4 measured reporting the identical Notepad window
|
||||
//! as 3 nodes against 26 on COM, so they cannot serve as COM expectations.
|
||||
//!
|
||||
//! Nothing in CI asserts what the captures contain - that would be an
|
||||
//! `app/provider` assertion R9 forbids. What is asserted here is the
|
||||
//! **redaction rule**: a capture that leaks a process id, a provider id, a
|
||||
//! window handle or a user path is a defect regardless of what tree it holds.
|
||||
|
||||
/// The committed dev-box captures, by the target variant each records.
|
||||
const CAPTURE_FILES: [&str; 2] = ["notepad-com.json", "explorer-com.json"];
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "captures_tests.rs"]
|
||||
mod tests;
|
||||
81
crates/windows/src/tree/captures_tests.rs
Normal file
81
crates/windows/src/tree/captures_tests.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
use super::*;
|
||||
|
||||
const NOTEPAD: &str =
|
||||
include_str!("../../../../docs/plans/2026-07-27-002-captures/notepad-com.json");
|
||||
const EXPLORER: &str =
|
||||
include_str!("../../../../docs/plans/2026-07-27-002-captures/explorer-com.json");
|
||||
|
||||
fn captures() -> [(&'static str, &'static str); 2] {
|
||||
[(CAPTURE_FILES[0], NOTEPAD), (CAPTURE_FILES[1], EXPLORER)]
|
||||
}
|
||||
|
||||
/// The redaction rule, not the contents. A capture that still carries a raw
|
||||
/// process id, provider id, window handle or user path publishes a fingerprint
|
||||
/// of the machine that produced it, and a partial substitution is worse than
|
||||
/// none because it reads as redacted.
|
||||
#[test]
|
||||
fn no_committed_capture_leaks_a_host_identifier() {
|
||||
for (name, body) in captures() {
|
||||
for (key, placeholder) in [("pid:", "<pid>"), ("providerId:", "<providerid>")] {
|
||||
for occurrence in body.split(key).skip(1) {
|
||||
assert!(
|
||||
occurrence.starts_with(placeholder),
|
||||
"{name} leaks a value after {key}: {}",
|
||||
&occurrence[..occurrence.len().min(24)]
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!body.to_ascii_lowercase().contains(r"\users\"),
|
||||
"{name} carries a user-profile path"
|
||||
);
|
||||
assert!(
|
||||
!body.to_ascii_lowercase().contains("hwnd:0x"),
|
||||
"{name} carries a raw window handle"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The metadata whose absence made 2.0's dumps unusable as COM expectations.
|
||||
#[test]
|
||||
fn every_committed_capture_records_its_variant_build_and_stack() {
|
||||
for (name, body) in captures() {
|
||||
let document: serde_json::Value =
|
||||
serde_json::from_str(body).unwrap_or_else(|_| panic!("{name} is valid JSON"));
|
||||
for field in ["target_variant", "os_build", "client_stack", "crate"] {
|
||||
let value = document
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(!value.is_empty(), "{name} does not record {field}");
|
||||
}
|
||||
assert_eq!(document["client_stack"], "uia3-com");
|
||||
}
|
||||
}
|
||||
|
||||
/// A capture that resolved nothing is not evidence. The tool reports an
|
||||
/// unresolvable target as a structured skip with a non-zero exit instead, so a
|
||||
/// committed capture always holds a tree the walker actually reached.
|
||||
#[test]
|
||||
fn no_committed_capture_is_an_empty_tree() {
|
||||
for (name, body) in captures() {
|
||||
let document: serde_json::Value =
|
||||
serde_json::from_str(body).unwrap_or_else(|_| panic!("{name} is valid JSON"));
|
||||
assert!(
|
||||
document["node_count"].as_u64().unwrap_or_default() > 0,
|
||||
"{name} records no nodes, which reads as evidence that the tree was empty"
|
||||
);
|
||||
assert!(
|
||||
document.get("walk").is_some(),
|
||||
"{name} records no walk verdict"
|
||||
);
|
||||
for census in ["control_types", "providers", "sample"] {
|
||||
assert!(
|
||||
document[census]
|
||||
.as_array()
|
||||
.is_some_and(|rows| !rows.is_empty()),
|
||||
"{name} records an empty {census} census"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
crates/windows/src/tree/element.rs
Normal file
73
crates/windows/src/tree/element.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use agent_desktop_core::{AdapterError, ErrorCode, NativeHandle};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
/// Owns one UI Automation element for the crate.
|
||||
///
|
||||
/// Refcounting is delegated on purpose. `uiautomation::UIElement` wraps a
|
||||
/// `windows-core` COM interface whose `Clone` is `AddRef` and whose `Drop`
|
||||
/// is `Release`, so a hand-written pair here would release twice. The
|
||||
/// encapsulation the macOS wrapper establishes is kept: the inner field is
|
||||
/// crate-visible only, there is no `Copy`, and there is no raw accessor.
|
||||
#[derive(Clone)]
|
||||
pub struct UIAElement(pub(crate) uiautomation::UIElement);
|
||||
|
||||
impl From<uiautomation::UIElement> for UIAElement {
|
||||
fn from(element: uiautomation::UIElement) -> Self {
|
||||
Self(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod imp {
|
||||
/// Canned stand-in so every tree module compiles on a non-Windows lane.
|
||||
#[derive(Clone)]
|
||||
pub struct UIAElement(pub(crate) CannedElement);
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CannedElement;
|
||||
|
||||
impl From<CannedElement> for UIAElement {
|
||||
fn from(element: CannedElement) -> Self {
|
||||
Self(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::UIAElement;
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub use imp::CannedElement;
|
||||
|
||||
impl UIAElement {
|
||||
/// Moves ownership of the element into a core `NativeHandle`.
|
||||
///
|
||||
/// By value, so a caller cannot keep a second reference to the same
|
||||
/// wrapper through this path.
|
||||
pub fn into_native_handle(self) -> NativeHandle {
|
||||
NativeHandle::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrows the UI Automation element carried by a core `NativeHandle`.
|
||||
///
|
||||
/// A handle built by another platform, or an empty one, is rejected rather
|
||||
/// than reinterpreted: `downcast_ref` is a type check, never a pointer cast.
|
||||
pub fn uia_element(handle: &NativeHandle) -> Result<&UIAElement, AdapterError> {
|
||||
handle.downcast_ref::<UIAElement>().ok_or_else(|| {
|
||||
AdapterError::new(
|
||||
ErrorCode::InvalidArgs,
|
||||
"Native handle does not contain a Windows UI Automation element",
|
||||
)
|
||||
.with_details(serde_json::json!({
|
||||
"kind": "invalid_native_handle",
|
||||
"platform": "windows",
|
||||
"empty": handle.is_null()
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "element_tests.rs"]
|
||||
mod tests;
|
||||
139
crates/windows/src/tree/element_properties.rs
Normal file
139
crates/windows/src/tree/element_properties.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use agent_desktop_core::{
|
||||
ElementIdentifier, IdentifierEvidence, IdentifierKind, LocatorEvidence, LocatorField,
|
||||
LocatorRefEvidence,
|
||||
};
|
||||
|
||||
use super::property_ids::TreeProperty;
|
||||
use super::property_outcome::{PropertyOutcome, PropertyValue};
|
||||
|
||||
/// Withholds a value-bearing property from a secure element.
|
||||
///
|
||||
/// Only a value that was actually read is replaced. A read that failed carries
|
||||
/// no content, so there is nothing to withhold — and rewriting it to `Absent`
|
||||
/// would claim the provider does not implement the property, which is false
|
||||
/// and is exactly the fabrication A14-9 forbids: `Absent` is a legitimate
|
||||
/// answer that satisfies `EvidenceRequirements`, and a target that never
|
||||
/// answered must not be able to satisfy them.
|
||||
fn withheld(outcome: PropertyOutcome) -> PropertyOutcome {
|
||||
match outcome {
|
||||
PropertyOutcome::Known(_) => PropertyOutcome::Absent,
|
||||
PropertyOutcome::Absent => PropertyOutcome::Absent,
|
||||
PropertyOutcome::Unknown => PropertyOutcome::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn withholds_content(outcome: &PropertyOutcome) -> bool {
|
||||
match outcome {
|
||||
PropertyOutcome::Known(PropertyValue::Flag(secure)) => *secure,
|
||||
PropertyOutcome::Known(PropertyValue::Number(secure)) => *secure != 0,
|
||||
PropertyOutcome::Known(_) => true,
|
||||
PropertyOutcome::Unknown => true,
|
||||
PropertyOutcome::Absent => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every property read for one element, already gated on `IsPassword`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ElementProperties {
|
||||
entries: Vec<(TreeProperty, PropertyOutcome)>,
|
||||
secure: bool,
|
||||
}
|
||||
|
||||
impl ElementProperties {
|
||||
pub fn from_reads(reads: Vec<(TreeProperty, PropertyOutcome)>) -> Self {
|
||||
let secure = reads
|
||||
.iter()
|
||||
.find(|(property, _)| *property == TreeProperty::IsPassword)
|
||||
.is_some_and(|(_, outcome)| withholds_content(outcome));
|
||||
let entries = reads
|
||||
.into_iter()
|
||||
.map(|(property, outcome)| {
|
||||
if secure && property.is_value_bearing() {
|
||||
(property, withheld(outcome))
|
||||
} else {
|
||||
(property, outcome)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self { entries, secure }
|
||||
}
|
||||
|
||||
/// Decides the secure-field gate from one `IsPassword` outcome.
|
||||
///
|
||||
/// Fails closed, because the cost is asymmetric: withholding a name that
|
||||
/// was not secret loses a little evidence, while publishing one that was
|
||||
/// puts a password into a snapshot, a session JSONL segment and a trace
|
||||
/// HTML export.
|
||||
///
|
||||
/// - `Known(true)` is the ordinary answer, and a non-zero integer is the
|
||||
/// same answer from a provider that returns `VT_I4` where UIA documents
|
||||
/// `VT_BOOL`. Reading only `VT_BOOL` would let such a provider open the
|
||||
/// gate.
|
||||
/// - `Unknown` means the read failed, which is not evidence the element is
|
||||
/// safe, so it withholds too.
|
||||
/// - `Absent` means the provider answered and does not implement the
|
||||
/// property, which is a real answer: not a password field.
|
||||
///
|
||||
/// An element whose read set never requested `IsPassword` is not gated at
|
||||
/// all - there was nothing to gate, and the caller chose the property set.
|
||||
pub fn is_secure(&self) -> bool {
|
||||
self.secure
|
||||
}
|
||||
|
||||
pub fn get(&self, property: TreeProperty) -> PropertyOutcome {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == property)
|
||||
.map(|(_, outcome)| outcome.clone())
|
||||
.unwrap_or(PropertyOutcome::Unknown)
|
||||
}
|
||||
|
||||
/// Projects the read set onto the evidence slot shape core consumes, so
|
||||
/// 2.4 needs no translation layer.
|
||||
///
|
||||
/// `role` and `available_actions` come from the 2.3 seams and are
|
||||
/// deliberately `Unknown` until 2.3 fills them; `states` likewise.
|
||||
/// `identifiers` uses `IdentifierEvidence::typed`, because
|
||||
/// `IdentifierEvidence::new` stamps every value `Unknown` and would void
|
||||
/// the ref downstream in `refs_validate.rs`.
|
||||
pub fn into_locator_evidence(
|
||||
self,
|
||||
role: LocatorField<String>,
|
||||
available_actions: LocatorField<Vec<String>>,
|
||||
) -> LocatorEvidence {
|
||||
let name = self.get(TreeProperty::Name).text();
|
||||
let value = self.get(TreeProperty::Value).text();
|
||||
let description = self.get(TreeProperty::HelpText).text();
|
||||
let bounds = self.get(TreeProperty::BoundingRectangle).bounds();
|
||||
LocatorEvidence {
|
||||
role,
|
||||
name,
|
||||
description,
|
||||
value,
|
||||
identifiers: self.identifier_evidence(),
|
||||
states: LocatorField::Unknown,
|
||||
ref_evidence: LocatorRefEvidence {
|
||||
bounds,
|
||||
available_actions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier_evidence(&self) -> IdentifierEvidence {
|
||||
let automation_id = self.get(TreeProperty::AutomationId);
|
||||
match automation_id {
|
||||
PropertyOutcome::Known(PropertyValue::Text(value)) if !value.trim().is_empty() => {
|
||||
IdentifierEvidence::typed(
|
||||
[ElementIdentifier {
|
||||
kind: IdentifierKind::AutomationId,
|
||||
value,
|
||||
}],
|
||||
Some(0),
|
||||
true,
|
||||
)
|
||||
}
|
||||
PropertyOutcome::Known(_) | PropertyOutcome::Absent => IdentifierEvidence::absent(),
|
||||
PropertyOutcome::Unknown => IdentifierEvidence::unknown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
91
crates/windows/src/tree/element_tests.rs
Normal file
91
crates/windows/src/tree/element_tests.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
use super::*;
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn canned_element() -> UIAElement {
|
||||
UIAElement::from(CannedElement)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_payload_never_masquerades_as_a_uia_element() {
|
||||
let foreign = NativeHandle::new(String::from("ax-token"));
|
||||
|
||||
let Err(error) = uia_element(&foreign) else {
|
||||
panic!("a macOS-shaped payload must be rejected");
|
||||
};
|
||||
|
||||
assert_eq!(error.code, ErrorCode::InvalidArgs);
|
||||
let details = error.details.expect("rejection carries details");
|
||||
assert_eq!(details["platform"], "windows");
|
||||
assert_eq!(details["empty"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_handle_is_rejected_without_a_pointer_cast() {
|
||||
let empty = NativeHandle::null();
|
||||
|
||||
let Err(error) = uia_element(&empty) else {
|
||||
panic!("an empty handle must be rejected");
|
||||
};
|
||||
|
||||
assert_eq!(error.code, ErrorCode::InvalidArgs);
|
||||
assert_eq!(
|
||||
error.details.expect("rejection carries details")["empty"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn a_wrapper_round_trips_through_a_native_handle() {
|
||||
let handle = canned_element().into_native_handle();
|
||||
|
||||
assert!(uia_element(&handle).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_only {
|
||||
use super::*;
|
||||
use crate::tree::automation::{automation_client, uia_error};
|
||||
|
||||
fn root_element() -> UIAElement {
|
||||
crate::tree::fixture::ensure_test_apartment();
|
||||
let client = automation_client().expect("a UIA client is available");
|
||||
UIAElement::from(
|
||||
client
|
||||
.get_root_element()
|
||||
.map_err(|error| uia_error(&error, "read the desktop root"))
|
||||
.expect("the desktop root resolves"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapper_round_trips_through_a_native_handle() {
|
||||
let element = root_element();
|
||||
let handle = element.into_native_handle();
|
||||
|
||||
assert!(uia_element(&handle).is_ok());
|
||||
}
|
||||
|
||||
/// The refcount contract is asserted by observation, not by reading the
|
||||
/// source: a clone is dropped and the survivor must still answer a live
|
||||
/// property read. A double-release would have invalidated the interface.
|
||||
#[test]
|
||||
fn dropping_a_clone_leaves_the_survivor_usable() {
|
||||
let element = root_element();
|
||||
let clone = element.clone();
|
||||
|
||||
drop(clone);
|
||||
|
||||
assert!(element.0.get_control_type().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_the_original_leaves_the_clone_usable() {
|
||||
let element = root_element();
|
||||
let clone = element.clone();
|
||||
|
||||
drop(element);
|
||||
|
||||
assert!(clone.0.get_control_type().is_ok());
|
||||
}
|
||||
}
|
||||
301
crates/windows/src/tree/fixture.rs
Normal file
301
crates/windows/src/tree/fixture.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
use std::thread::{JoinHandle, spawn};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::fixture_window;
|
||||
|
||||
pub(crate) use super::fixture_window::{CONTENT_MARKER, SECURE_MARKER};
|
||||
|
||||
const HOST_ENVIRONMENT_FLAG: &str = "AGENT_DESKTOP_FIXTURE_HOST";
|
||||
const HOST_TEST_NAME: &str = "tree::fixture::tests::fixture_host_process_entry";
|
||||
const HANDLE_PREFIX: &str = "AGENT_DESKTOP_FIXTURE_HWND=";
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const HOST_WATCHDOG_LIFETIME: Duration = Duration::from_secs(300);
|
||||
const WALKABLE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const WALKABLE_POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Joins this thread to the multithreaded apartment for a test.
|
||||
///
|
||||
/// `ensure_owned_process_mta_and_dpi` is wrong here and the reason is not
|
||||
/// cosmetic: `CoInitializeEx` is thread-local while that bootstrap's guard is
|
||||
/// process-wide, so in a multi-threaded test binary only the first thread to
|
||||
/// call it ever joins the apartment and every other thread sees
|
||||
/// `CO_E_NOTINITIALIZED` from `CoCreateInstance`. Its own contract says as
|
||||
/// much - it is sound for the CLI because the CLI calls it once from `main`
|
||||
/// before any COM work. Libtest's worker threads are threads this product
|
||||
/// does not own, which is exactly the case `CoIncrementMTAUsage` exists for.
|
||||
/// The single entry every Windows-gated test uses before touching UI
|
||||
/// Automation. Four modules previously each defined their own one-line
|
||||
/// `bootstrap()` around this call.
|
||||
pub(crate) fn bootstrap() {
|
||||
ensure_test_apartment();
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_test_apartment() {
|
||||
crate::system::com_runtime::ensure_hosted_library_mta_and_dpi()
|
||||
.expect("the process-wide MTA registration succeeds");
|
||||
}
|
||||
|
||||
/// Reports whether this process was re-executed to host a fixture window.
|
||||
pub(crate) fn is_host_process() -> bool {
|
||||
std::env::var(HOST_ENVIRONMENT_FLAG).is_ok()
|
||||
}
|
||||
|
||||
/// Creates the window and pumps until the parent ends this process.
|
||||
///
|
||||
/// Only the re-executed child calls it. The watchdog bounds the process even
|
||||
/// if the parent is killed without running `HostedFixture`'s `Drop`, so a
|
||||
/// crashed test run cannot leave a window host behind on a shared runner.
|
||||
pub(crate) fn run_as_host() {
|
||||
let (sender, receiver) = channel::<Result<fixture_window::PumpHandle, String>>();
|
||||
spawn(move || {
|
||||
if let Ok(Ok(running)) = receiver.recv_timeout(READY_TIMEOUT) {
|
||||
println!("{HANDLE_PREFIX}{}", running.window);
|
||||
}
|
||||
});
|
||||
spawn(|| {
|
||||
std::thread::sleep(HOST_WATCHDOG_LIFETIME);
|
||||
std::process::exit(0);
|
||||
});
|
||||
fixture_window::host_window(&fixture_window::unique_class_name(), sender);
|
||||
}
|
||||
|
||||
/// A fixture window hosted in a second process.
|
||||
///
|
||||
/// This is the default for walk and cache tests. A window the test process
|
||||
/// creates itself is served by in-process client-side providers, so the
|
||||
/// failure taxonomy the walker exists to classify - RPC failure against
|
||||
/// exhaustion, a target that stops pumping - is structurally unreachable
|
||||
/// from it, and a cache policy validated there is validated against exactly
|
||||
/// the provider class the policy says to skip.
|
||||
pub(crate) struct HostedFixture {
|
||||
child: Option<Child>,
|
||||
handle: isize,
|
||||
}
|
||||
|
||||
impl HostedFixture {
|
||||
pub(crate) fn spawn() -> Result<Self, String> {
|
||||
let executable = std::env::current_exe().map_err(|error| error.to_string())?;
|
||||
let mut child = Command::new(executable)
|
||||
.args(["--exact", HOST_TEST_NAME, "--ignored", "--nocapture"])
|
||||
.env(HOST_ENVIRONMENT_FLAG, "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| String::from("the fixture host exposed no stdout"))?;
|
||||
let (sender, receiver) = channel();
|
||||
spawn(move || {
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
if let Some(handle) = line.trim().strip_prefix(HANDLE_PREFIX) {
|
||||
let _ = sender.send(handle.trim().parse::<isize>().unwrap_or(0));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = sender.send(0);
|
||||
});
|
||||
let handle = match receiver.recv_timeout(READY_TIMEOUT) {
|
||||
Ok(handle) if handle != 0 => handle,
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(String::from(
|
||||
"the fixture host never reported a window handle",
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) = await_walkable(handle) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Self {
|
||||
child: Some(child),
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle(&self) -> isize {
|
||||
self.handle
|
||||
}
|
||||
|
||||
pub(crate) fn process_id(&self) -> u32 {
|
||||
self.child.as_ref().map(Child::id).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ends the host process and waits for it, so a test can observe what the
|
||||
/// walk does against a provider that has genuinely gone away.
|
||||
pub(crate) fn terminate(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HostedFixture {
|
||||
fn drop(&mut self) {
|
||||
self.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixture window hosted on a worker thread of the test process.
|
||||
///
|
||||
/// Retained only for teardown and concurrency tests. Never use it to validate
|
||||
/// the walk's failure classification or the cache policy.
|
||||
pub(crate) struct LocalFixture {
|
||||
handle: isize,
|
||||
pump_thread_id: u32,
|
||||
class_name: String,
|
||||
pump: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LocalFixture {
|
||||
pub(crate) fn create() -> Result<Self, String> {
|
||||
let class_name = fixture_window::unique_class_name();
|
||||
let (sender, receiver) = channel();
|
||||
let pump = spawn({
|
||||
let class_name = class_name.clone();
|
||||
move || host_on_this_thread(&class_name, sender)
|
||||
});
|
||||
let running = match receiver.recv_timeout(READY_TIMEOUT) {
|
||||
Ok(Ok(running)) => running,
|
||||
Ok(Err(error)) => return Err(error),
|
||||
Err(_) => return Err(String::from("the fixture window never became ready")),
|
||||
};
|
||||
await_walkable(running.window)?;
|
||||
Ok(Self {
|
||||
handle: running.window,
|
||||
pump_thread_id: running.thread_id,
|
||||
class_name,
|
||||
pump: Some(pump),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle(&self) -> isize {
|
||||
self.handle
|
||||
}
|
||||
|
||||
pub(crate) fn geometry(&self) -> fixture_window::WindowGeometry {
|
||||
fixture_window::geometry(self.handle)
|
||||
}
|
||||
|
||||
/// Identifies the thread that owns the window and runs its pump, so a
|
||||
/// test can assert that no UI Automation call is issued from it.
|
||||
pub(crate) fn pump_thread_id(&self) -> Option<std::thread::ThreadId> {
|
||||
self.pump.as_ref().map(|pump| pump.thread().id())
|
||||
}
|
||||
|
||||
pub(crate) fn minimize(&self) {
|
||||
fixture_window::minimize_window(self.handle);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LocalFixture {
|
||||
fn drop(&mut self) {
|
||||
fixture_window::close_window(self.handle);
|
||||
fixture_window::quit_pump(self.pump_thread_id);
|
||||
if let Some(pump) = self.pump.take() {
|
||||
let _ = pump.join();
|
||||
}
|
||||
fixture_window::destroy_window(self.handle);
|
||||
fixture_window::unregister_class(&self.class_name);
|
||||
}
|
||||
}
|
||||
|
||||
/// A window whose thread owns it but never dispatches its messages.
|
||||
///
|
||||
/// The host thread cannot be joined the ordinary way - joining means waiting
|
||||
/// for it to finish, and finishing means it stopped owning the window this
|
||||
/// fixture exists to keep stalled. Instead it is told to stop and polls for
|
||||
/// that signal, so teardown ends it in milliseconds rather than leaving a
|
||||
/// parked thread per test.
|
||||
pub(crate) struct StalledFixture {
|
||||
handle: isize,
|
||||
class_name: String,
|
||||
stop: Arc<AtomicBool>,
|
||||
host: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl StalledFixture {
|
||||
pub(crate) fn create() -> Result<Self, String> {
|
||||
let class_name = fixture_window::unique_class_name();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (sender, receiver) = channel();
|
||||
let host = spawn({
|
||||
let class_name = class_name.clone();
|
||||
let stop = stop.clone();
|
||||
move || fixture_window::stalled_window(&class_name, sender, stop)
|
||||
});
|
||||
match receiver.recv_timeout(READY_TIMEOUT) {
|
||||
Ok(Ok(handle)) => Ok(Self {
|
||||
handle,
|
||||
class_name,
|
||||
stop,
|
||||
host: Some(host),
|
||||
}),
|
||||
Ok(Err(error)) => Err(error),
|
||||
Err(_) => {
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
Err(String::from("the stalled window never became ready"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle(&self) -> isize {
|
||||
self.handle
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StalledFixture {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(host) = self.host.take() {
|
||||
let _ = host.join();
|
||||
}
|
||||
fixture_window::unregister_class(&self.class_name);
|
||||
}
|
||||
}
|
||||
|
||||
fn host_on_this_thread(
|
||||
class_name: &str,
|
||||
ready: Sender<Result<fixture_window::PumpHandle, String>>,
|
||||
) {
|
||||
fixture_window::host_window(class_name, ready);
|
||||
}
|
||||
|
||||
/// Blocks until the window actually resolves to a UI Automation root.
|
||||
///
|
||||
/// A pumping window is necessary but not sufficient. `ElementFromHandle`
|
||||
/// sends `WM_GETOBJECT`, and that `SendMessage` carries its own timeout, so on
|
||||
/// a loaded runner starting several fixture hosts at once the first call can
|
||||
/// return `E_FAIL` against a window that is perfectly healthy a moment later.
|
||||
/// The fixture's contract is a walkable window, so it does not hand one out
|
||||
/// until it is one - which keeps the retry in the harness rather than
|
||||
/// smuggling it into the product's resolver.
|
||||
fn await_walkable(handle: isize) -> Result<(), String> {
|
||||
let deadline = std::time::Instant::now() + WALKABLE_TIMEOUT;
|
||||
let mut last = String::from("the fixture window never resolved");
|
||||
while std::time::Instant::now() < deadline {
|
||||
match crate::tree::automation::root_from_hwnd(
|
||||
handle,
|
||||
agent_desktop_core::Deadline::standard().map_err(|error| error.message.clone())?,
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => last = error.message.clone(),
|
||||
}
|
||||
std::thread::sleep(WALKABLE_POLL);
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fixture_tests.rs"]
|
||||
mod tests;
|
||||
264
crates/windows/src/tree/fixture_tests.rs
Normal file
264
crates/windows/src/tree/fixture_tests.rs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
use super::*;
|
||||
use crate::tree::automation::{automation_client, uia_error};
|
||||
use crate::tree::fixture::bootstrap;
|
||||
use agent_desktop_core::Deadline;
|
||||
use std::time::Duration;
|
||||
use uiautomation::types::UIProperty;
|
||||
use uiautomation::{UIElement, UITreeWalker};
|
||||
|
||||
const SETTLE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The re-executed child process's entry point. Ignored so a normal run never
|
||||
/// starts it; `HostedFixture::spawn` selects it by exact name.
|
||||
#[test]
|
||||
#[ignore = "runs only in the re-executed fixture host process"]
|
||||
fn fixture_host_process_entry() {
|
||||
assert!(
|
||||
is_host_process(),
|
||||
"the host entry must not run without the host flag"
|
||||
);
|
||||
run_as_host();
|
||||
}
|
||||
|
||||
fn raw_view_walker() -> UITreeWalker {
|
||||
let client = automation_client().expect("a UIA client is available");
|
||||
client
|
||||
.get_raw_view_walker()
|
||||
.map_err(|error| uia_error(&error, "create a raw view walker"))
|
||||
.expect("the raw view walker is available")
|
||||
}
|
||||
|
||||
fn direct_children(walker: &UITreeWalker, parent: &UIElement) -> Vec<UIElement> {
|
||||
let mut children = Vec::new();
|
||||
let Ok(mut current) = walker.get_first_child(parent) else {
|
||||
return children;
|
||||
};
|
||||
loop {
|
||||
let next = walker.get_next_sibling(¤t);
|
||||
children.push(current);
|
||||
match next {
|
||||
Ok(sibling) => current = sibling,
|
||||
Err(_) => return children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn descendant_count(walker: &UITreeWalker, root: &UIElement, depth: u32) -> usize {
|
||||
if depth >= 8 {
|
||||
return 0;
|
||||
}
|
||||
let children = direct_children(walker, root);
|
||||
children.iter().fold(children.len(), |total, child| {
|
||||
total + descendant_count(walker, child, depth + 1)
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve(handle: isize) -> crate::tree::element::UIAElement {
|
||||
crate::tree::automation::root_from_hwnd(
|
||||
handle,
|
||||
Deadline::standard().expect("a standard deadline"),
|
||||
)
|
||||
.expect("the fixture window resolves to a UI Automation root")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_process_fixture_resolves_a_root_that_exposes_its_controls() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
|
||||
assert_ne!(fixture.process_id(), std::process::id());
|
||||
|
||||
let root = resolve(fixture.handle());
|
||||
let walker = raw_view_walker();
|
||||
|
||||
assert!(
|
||||
descendant_count(&walker, &root.0, 0) > 0,
|
||||
"a cross-process fixture root must expose descendants"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixture_window_is_visible_with_a_non_zero_rect() {
|
||||
let fixture = LocalFixture::create().expect("the fixture window is created");
|
||||
let geometry = fixture.geometry();
|
||||
|
||||
assert!(geometry.visible, "the provider excludes invisible windows");
|
||||
assert!(geometry.width > 0 && geometry.height > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixture_tears_down_and_a_second_one_succeeds_in_the_same_process() {
|
||||
bootstrap();
|
||||
let first = LocalFixture::create().expect("the first fixture is created");
|
||||
let first_handle = first.handle();
|
||||
drop(first);
|
||||
|
||||
let second = LocalFixture::create().expect("the second fixture is created");
|
||||
|
||||
assert_ne!(second.handle(), 0);
|
||||
assert!(!second.geometry().visible || second.geometry().width > 0);
|
||||
assert!(
|
||||
crate::tree::automation::root_from_hwnd(
|
||||
first_handle,
|
||||
Deadline::standard().expect("a standard deadline"),
|
||||
)
|
||||
.is_err(),
|
||||
"a torn-down fixture must not still resolve"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_fixtures_created_concurrently_do_not_interfere() {
|
||||
bootstrap();
|
||||
let first = LocalFixture::create().expect("the first fixture is created");
|
||||
let second = LocalFixture::create().expect("the second fixture is created");
|
||||
|
||||
assert_ne!(first.handle(), second.handle());
|
||||
|
||||
let walker = raw_view_walker();
|
||||
let first_root = resolve(first.handle());
|
||||
let second_root = resolve(second.handle());
|
||||
|
||||
assert!(descendant_count(&walker, &first_root.0, 0) > 0);
|
||||
assert!(descendant_count(&walker, &second_root.0, 0) > 0);
|
||||
}
|
||||
|
||||
/// KTD9: the client automating its own UI must call from a thread that owns
|
||||
/// no windows. A call issued on the pump thread would block on its own
|
||||
/// `WM_GETOBJECT` and deadlock, so the harness never hands that thread out.
|
||||
#[test]
|
||||
fn every_uia_call_is_issued_from_a_thread_that_owns_no_fixture_window() {
|
||||
bootstrap();
|
||||
let fixture = LocalFixture::create().expect("the fixture window is created");
|
||||
|
||||
let pump = fixture
|
||||
.pump_thread_id()
|
||||
.expect("the pump thread is running");
|
||||
assert_ne!(std::thread::current().id(), pump);
|
||||
|
||||
let root = resolve(fixture.handle());
|
||||
|
||||
assert!(root.0.get_control_type().is_ok());
|
||||
}
|
||||
|
||||
/// The rule A1-2 establishes for a minimized window, not this box's `-32000`
|
||||
/// literal: minimizing must not truncate the tree, and it degenerates
|
||||
/// geometry in two different shapes at once - the top level reports an empty
|
||||
/// rectangle while its descendants keep real extents.
|
||||
///
|
||||
/// A14-8 records where this fixture diverges from A1-2 on the COM stack:
|
||||
/// `IsOffscreen` is not false throughout. The minimized top level reports
|
||||
/// true while every descendant reports false, so the assertion here is the
|
||||
/// completeness and geometry rule, never an `IsOffscreen` value.
|
||||
#[test]
|
||||
fn minimizing_degenerates_geometry_without_truncating_the_tree() {
|
||||
bootstrap();
|
||||
let fixture = LocalFixture::create().expect("the fixture window is created");
|
||||
let walker = raw_view_walker();
|
||||
let restored = descendant_count(&walker, &resolve(fixture.handle()).0, 0);
|
||||
assert!(restored > 0, "the fixture exposes child controls");
|
||||
|
||||
fixture.minimize();
|
||||
std::thread::sleep(SETTLE);
|
||||
|
||||
let minimized = resolve(fixture.handle());
|
||||
assert_eq!(
|
||||
descendant_count(&walker, &minimized.0, 0),
|
||||
restored,
|
||||
"Windows Engineering Invariant 10: a minimized window stays fully walkable"
|
||||
);
|
||||
|
||||
let top_level = minimized
|
||||
.0
|
||||
.get_bounding_rectangle()
|
||||
.expect("the top level reports a rectangle");
|
||||
assert!(
|
||||
is_empty(&top_level),
|
||||
"the minimized top level degenerates to an empty rectangle"
|
||||
);
|
||||
let descendants_with_extent = direct_children(&walker, &minimized.0)
|
||||
.iter()
|
||||
.filter_map(|child| child.get_bounding_rectangle().ok())
|
||||
.filter(|rectangle| !is_empty(rectangle))
|
||||
.count();
|
||||
assert!(
|
||||
descendants_with_extent > 0,
|
||||
"an empty top-level rectangle is not the only degenerate shape - descendants keep real extents"
|
||||
);
|
||||
}
|
||||
|
||||
fn is_empty(rectangle: &uiautomation::types::Rect) -> bool {
|
||||
rectangle.get_right() - rectangle.get_left() == 0
|
||||
&& rectangle.get_bottom() - rectangle.get_top() == 0
|
||||
}
|
||||
|
||||
/// The fixture must actually be able to demonstrate the secure-field gate
|
||||
/// before U5 relies on it: the plain control's text has to be readable, and
|
||||
/// the password control's text has to be a real secret that a read could
|
||||
/// leak. A fixture that failed either half would make U5's gate untestable.
|
||||
#[test]
|
||||
fn the_fixture_exposes_plain_content_and_withholds_secure_content() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let root = resolve(fixture.handle());
|
||||
let walker = raw_view_walker();
|
||||
let children = direct_children(&walker, &root.0);
|
||||
|
||||
let readable = children
|
||||
.iter()
|
||||
.filter_map(|child| child.get_property_value(UIProperty::ValueValue).ok())
|
||||
.filter_map(|value| value.get_string().ok())
|
||||
.any(|value| value.contains(CONTENT_MARKER));
|
||||
assert!(
|
||||
readable,
|
||||
"the fixture's plain control must expose its content, or the gate has nothing to gate"
|
||||
);
|
||||
|
||||
let secure = children
|
||||
.iter()
|
||||
.find(|child| child.is_password().unwrap_or(false))
|
||||
.expect("the fixture exposes a control reporting IsPassword");
|
||||
for property in [
|
||||
UIProperty::ValueValue,
|
||||
UIProperty::LegacyIAccessibleValue,
|
||||
UIProperty::Name,
|
||||
UIProperty::HelpText,
|
||||
] {
|
||||
let read = secure
|
||||
.get_property_value(property)
|
||||
.ok()
|
||||
.and_then(|value| value.get_string().ok())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!read.contains(SECURE_MARKER),
|
||||
"a secure control leaked its content through {property:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Teardown must not depend on the window still existing.
|
||||
///
|
||||
/// `WM_CLOSE` reaches the pump only through the window, so a window destroyed
|
||||
/// out from under the fixture would leave `GetMessageW` blocked and turn the
|
||||
/// joining `Drop` into a hang of the whole test binary - which on CI burns the
|
||||
/// lane's timeout and produces no diagnostic at all. The watchdog makes a
|
||||
/// regression fail loudly instead of hanging.
|
||||
#[test]
|
||||
fn a_fixture_whose_window_is_already_destroyed_still_tears_down() {
|
||||
let fixture = LocalFixture::create().expect("the fixture window is created");
|
||||
let handle = fixture.handle();
|
||||
let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let watchdog = done.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_secs(20));
|
||||
assert!(
|
||||
watchdog.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"fixture teardown hung after its window was destroyed"
|
||||
);
|
||||
});
|
||||
|
||||
crate::tree::fixture_window::destroy_window(handle);
|
||||
drop(fixture);
|
||||
|
||||
done.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
349
crates/windows/src/tree/fixture_window.rs
Normal file
349
crates/windows/src/tree/fixture_window.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
|
||||
use windows_sys::Win32::System::ApplicationInstallationAndServicing::{
|
||||
ACTCTXW, ActivateActCtx, CreateActCtxW,
|
||||
};
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW, GetWindowRect,
|
||||
IDC_ARROW, IsWindowVisible, LoadCursorW, MSG, PostMessageW, PostQuitMessage,
|
||||
PostThreadMessageW, RegisterClassExW, SW_MINIMIZE, SW_SHOWNOACTIVATE, SetWindowTextW,
|
||||
ShowWindow, TranslateMessage, UnregisterClassW, WM_CLOSE, WM_DESTROY, WM_QUIT, WNDCLASSEXW,
|
||||
WS_CHILD, WS_OVERLAPPEDWINDOW, WS_VISIBLE,
|
||||
};
|
||||
|
||||
const ES_PASSWORD: u32 = 0x0020;
|
||||
/// Private message the host posts to itself before entering its pump, so
|
||||
/// readiness is announced from inside the pump rather than before it.
|
||||
///
|
||||
/// `ElementFromHandle` sends `WM_GETOBJECT` and a cross-thread `SendMessage`
|
||||
/// blocks until the receiving thread dispatches, so a handle announced before
|
||||
/// the pump starts lets a loaded machine call in during the gap and observe
|
||||
/// `E_FAIL`.
|
||||
const WM_FIXTURE_READY: u32 = 0x0400 + 1;
|
||||
const CONTROL_BORDER: u32 = 0x0080_0000;
|
||||
const OFFSCREEN_LEFT: i32 = 2_000;
|
||||
const OFFSCREEN_TOP: i32 = 2_000;
|
||||
const WINDOW_WIDTH: i32 = 420;
|
||||
const WINDOW_HEIGHT: i32 = 320;
|
||||
|
||||
/// The `comctl32` v6 side-by-side manifest. Without an activation context the
|
||||
/// v5 common controls are bound, and the standard controls the fixture creates
|
||||
/// do not get their full UI Automation support.
|
||||
const COMCTL32_V6_MANIFEST: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df"
|
||||
language="*" />
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
"#;
|
||||
|
||||
/// Text written into the fixture's `ES_PASSWORD` control. A read outcome that
|
||||
/// contains this string has leaked secure content.
|
||||
pub(crate) const SECURE_MARKER: &str = "zzfixturesecretzz";
|
||||
|
||||
/// Text written into the fixture's plain `EDIT` control, so a redaction test
|
||||
/// has a value that a failing read could plausibly carry into an error.
|
||||
pub(crate) const CONTENT_MARKER: &str = "zzfixturecontentzz";
|
||||
|
||||
static CLASS_SEQUENCE: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Names the control the fixture creates so a test can select it by role
|
||||
/// without matching on a localized string.
|
||||
pub(crate) struct FixtureControls {
|
||||
pub(crate) button: HWND,
|
||||
pub(crate) edit: HWND,
|
||||
pub(crate) password: HWND,
|
||||
}
|
||||
|
||||
/// A running fixture pump: the window it owns and the thread that pumps it.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct PumpHandle {
|
||||
pub(crate) window: isize,
|
||||
pub(crate) thread_id: u32,
|
||||
}
|
||||
|
||||
pub(crate) struct WindowGeometry {
|
||||
pub(crate) visible: bool,
|
||||
pub(crate) width: i32,
|
||||
pub(crate) height: i32,
|
||||
}
|
||||
|
||||
fn wide(text: &str) -> Vec<u16> {
|
||||
text.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Mints a class name unique to this process and call.
|
||||
///
|
||||
/// `RegisterClassExW` fails with `ERROR_CLASS_ALREADY_EXISTS` when a second
|
||||
/// fixture re-registers the same name, and the test binary runs its cases in
|
||||
/// parallel threads, so a shared name would make concurrent fixtures race.
|
||||
pub(crate) fn unique_class_name() -> String {
|
||||
let sequence = CLASS_SEQUENCE.fetch_add(1, Ordering::SeqCst);
|
||||
format!("AgentDesktopFixture-{}-{}", std::process::id(), sequence)
|
||||
}
|
||||
|
||||
unsafe extern "system" fn window_proc(
|
||||
window: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
if message == WM_DESTROY {
|
||||
unsafe { PostQuitMessage(0) };
|
||||
return 0;
|
||||
}
|
||||
unsafe { DefWindowProcW(window, message, wparam, lparam) }
|
||||
}
|
||||
|
||||
fn activate_common_controls_v6() {
|
||||
static ACTIVATED: std::sync::Once = std::sync::Once::new();
|
||||
ACTIVATED.call_once(install_common_controls_v6);
|
||||
}
|
||||
|
||||
/// The activation context is process-wide, so writing the manifest and
|
||||
/// creating the context once per fixture wrote the same file repeatedly and
|
||||
/// leaked a context per window.
|
||||
fn install_common_controls_v6() {
|
||||
let directory = std::env::temp_dir().join("agent-desktop-fixture-manifests");
|
||||
let _ = std::fs::create_dir_all(&directory);
|
||||
let path = directory.join(format!("comctl32-v6-{}.manifest", std::process::id()));
|
||||
if std::fs::write(&path, COMCTL32_V6_MANIFEST).is_err() {
|
||||
return;
|
||||
}
|
||||
let source = wide(&path.to_string_lossy());
|
||||
let context = ACTCTXW {
|
||||
cbSize: size_of::<ACTCTXW>() as u32,
|
||||
lpSource: source.as_ptr(),
|
||||
..Default::default()
|
||||
};
|
||||
let handle = unsafe { CreateActCtxW(&context) };
|
||||
if !handle.is_null() && handle as isize != -1 {
|
||||
let mut cookie = 0usize;
|
||||
unsafe { ActivateActCtx(handle, &mut cookie) };
|
||||
}
|
||||
}
|
||||
|
||||
fn register_class(class_name: &str) -> Result<(), String> {
|
||||
let name = wide(class_name);
|
||||
let class = WNDCLASSEXW {
|
||||
cbSize: size_of::<WNDCLASSEXW>() as u32,
|
||||
lpfnWndProc: Some(window_proc),
|
||||
hInstance: unsafe { GetModuleHandleW(std::ptr::null()) },
|
||||
hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) },
|
||||
lpszClassName: name.as_ptr(),
|
||||
..Default::default()
|
||||
};
|
||||
if unsafe { RegisterClassExW(&class) } == 0 {
|
||||
return Err(format!("RegisterClassExW rejected the class {class_name}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unregister_class(class_name: &str) {
|
||||
let name = wide(class_name);
|
||||
unsafe {
|
||||
UnregisterClassW(name.as_ptr(), GetModuleHandleW(std::ptr::null()));
|
||||
}
|
||||
}
|
||||
|
||||
fn control(parent: HWND, class: &str, text: &str, style: u32, top: i32) -> HWND {
|
||||
let class = wide(class);
|
||||
let text = wide(text);
|
||||
unsafe {
|
||||
CreateWindowExW(
|
||||
0,
|
||||
class.as_ptr(),
|
||||
text.as_ptr(),
|
||||
WS_CHILD | WS_VISIBLE | style,
|
||||
8,
|
||||
top,
|
||||
200,
|
||||
24,
|
||||
parent,
|
||||
std::ptr::null_mut(),
|
||||
GetModuleHandleW(std::ptr::null()),
|
||||
std::ptr::null(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the fixture window on the calling thread and pumps its message
|
||||
/// queue until the window is destroyed.
|
||||
///
|
||||
/// The caller must be a thread that does nothing else afterwards.
|
||||
/// `ElementFromHandle` sends `WM_GETOBJECT`, and a cross-thread `SendMessage`
|
||||
/// blocks until the receiving thread dispatches it, so a thread that both
|
||||
/// hosts the window and waits on a UI Automation result deadlocks.
|
||||
///
|
||||
/// The window is shown with `SW_SHOWNOACTIVATE` at an off-screen origin and a
|
||||
/// non-zero size: `HwndProxyElementProvider` excludes windows that fail
|
||||
/// `IsWindowVisible` or report a zero-area rect, so `SW_HIDE` and a
|
||||
/// message-only window are both unusable here.
|
||||
pub(crate) fn host_window(class_name: &str, ready: Sender<Result<PumpHandle, String>>) {
|
||||
activate_common_controls_v6();
|
||||
if let Err(error) = register_class(class_name) {
|
||||
let _ = ready.send(Err(error));
|
||||
return;
|
||||
}
|
||||
let name = wide(class_name);
|
||||
let title = wide("agent-desktop fixture");
|
||||
let window = unsafe {
|
||||
CreateWindowExW(
|
||||
0,
|
||||
name.as_ptr(),
|
||||
title.as_ptr(),
|
||||
WS_OVERLAPPEDWINDOW,
|
||||
OFFSCREEN_LEFT,
|
||||
OFFSCREEN_TOP,
|
||||
WINDOW_WIDTH,
|
||||
WINDOW_HEIGHT,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
GetModuleHandleW(std::ptr::null()),
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if window.is_null() {
|
||||
let _ = ready.send(Err("CreateWindowExW produced no window".into()));
|
||||
return;
|
||||
}
|
||||
let controls = create_controls(window);
|
||||
debug_assert!(!controls.button.is_null());
|
||||
debug_assert!(!controls.edit.is_null());
|
||||
debug_assert!(!controls.password.is_null());
|
||||
unsafe { ShowWindow(window, SW_SHOWNOACTIVATE) };
|
||||
unsafe { PostMessageW(window, WM_FIXTURE_READY, 0, 0) };
|
||||
pump_until_destroyed(window as isize, ready);
|
||||
}
|
||||
|
||||
fn create_controls(window: HWND) -> FixtureControls {
|
||||
let button = control(window, "BUTTON", "fixture-button", CONTROL_BORDER, 8);
|
||||
control(window, "STATIC", "fixture-static", 0, 40);
|
||||
let edit = control(window, "EDIT", CONTENT_MARKER, CONTROL_BORDER, 72);
|
||||
let password = control(window, "EDIT", "", CONTROL_BORDER | ES_PASSWORD, 104);
|
||||
let secret = wide(SECURE_MARKER);
|
||||
unsafe { SetWindowTextW(password, secret.as_ptr()) };
|
||||
FixtureControls {
|
||||
button,
|
||||
edit,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
fn pump_until_destroyed(handle: isize, ready: Sender<Result<PumpHandle, String>>) {
|
||||
let thread_id = unsafe { GetCurrentThreadId() };
|
||||
let mut message = MSG::default();
|
||||
let mut announced = false;
|
||||
while unsafe { GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) } > 0 {
|
||||
unsafe { TranslateMessage(&message) };
|
||||
unsafe { DispatchMessageW(&message) };
|
||||
if !announced && message.message == WM_FIXTURE_READY {
|
||||
announced = true;
|
||||
let _ = ready.send(Ok(PumpHandle {
|
||||
window: handle,
|
||||
thread_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn close_window(handle: isize) {
|
||||
unsafe { PostMessageW(handle as *mut c_void, WM_CLOSE, 0, 0) };
|
||||
}
|
||||
|
||||
/// Ends a pump whose window may already be gone.
|
||||
///
|
||||
/// `WM_CLOSE` reaches the pump only through the window, so a window destroyed
|
||||
/// out from under the fixture leaves `GetMessageW` blocked forever and turns a
|
||||
/// joining teardown into a hang of the whole test binary rather than a
|
||||
/// failure. `WM_QUIT` is posted to the *thread* queue, which `GetMessageW`
|
||||
/// answers with 0 whatever the window's state.
|
||||
pub(crate) fn quit_pump(thread_id: u32) {
|
||||
unsafe { PostThreadMessageW(thread_id, WM_QUIT, 0, 0) };
|
||||
}
|
||||
|
||||
pub(crate) fn minimize_window(handle: isize) {
|
||||
unsafe { ShowWindow(handle as *mut c_void, SW_MINIMIZE) };
|
||||
}
|
||||
|
||||
pub(crate) fn destroy_window(handle: isize) {
|
||||
unsafe { DestroyWindow(handle as *mut c_void) };
|
||||
}
|
||||
|
||||
/// How often a stalled host looks for its stop signal.
|
||||
///
|
||||
/// Sleeping is not pumping - the queue is still never serviced, so the window
|
||||
/// stays stalled for a `SendMessage` - but it lets teardown end the thread in
|
||||
/// milliseconds instead of leaving one parked per test for two minutes.
|
||||
const STALL_POLL: std::time::Duration = std::time::Duration::from_millis(25);
|
||||
|
||||
/// Creates a window and then deliberately never pumps its queue.
|
||||
///
|
||||
/// The 2.2 plan records "whether a non-pumping target produces a clean timeout
|
||||
/// or a hang is unverified, and the fixture cannot produce the condition". It
|
||||
/// can: `CreateWindowExW` dispatches `WM_CREATE` inline, so a thread can own a
|
||||
/// live window and then stop dispatching. That makes the resolver's pump probe
|
||||
/// testable instead of assumed.
|
||||
pub(crate) fn stalled_window(
|
||||
class_name: &str,
|
||||
ready: Sender<Result<isize, String>>,
|
||||
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
) {
|
||||
if let Err(error) = register_class(class_name) {
|
||||
let _ = ready.send(Err(error));
|
||||
return;
|
||||
}
|
||||
let name = wide(class_name);
|
||||
let title = wide("agent-desktop stalled fixture");
|
||||
let window = unsafe {
|
||||
CreateWindowExW(
|
||||
0,
|
||||
name.as_ptr(),
|
||||
title.as_ptr(),
|
||||
WS_OVERLAPPEDWINDOW,
|
||||
OFFSCREEN_LEFT,
|
||||
OFFSCREEN_TOP,
|
||||
WINDOW_WIDTH,
|
||||
WINDOW_HEIGHT,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
GetModuleHandleW(std::ptr::null()),
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if window.is_null() {
|
||||
let _ = ready.send(Err("CreateWindowExW produced no window".into()));
|
||||
return;
|
||||
}
|
||||
unsafe { ShowWindow(window, SW_SHOWNOACTIVATE) };
|
||||
let _ = ready.send(Ok(window as isize));
|
||||
while !stop.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
std::thread::sleep(STALL_POLL);
|
||||
}
|
||||
unsafe { DestroyWindow(window) };
|
||||
}
|
||||
|
||||
pub(crate) fn geometry(handle: isize) -> WindowGeometry {
|
||||
let window = handle as *mut c_void;
|
||||
let mut rect = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
};
|
||||
let read = unsafe { GetWindowRect(window, &mut rect) };
|
||||
WindowGeometry {
|
||||
visible: unsafe { IsWindowVisible(window) } != 0,
|
||||
width: if read != 0 { rect.right - rect.left } else { 0 },
|
||||
height: if read != 0 { rect.bottom - rect.top } else { 0 },
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,21 @@
|
|||
pub mod automation;
|
||||
pub(crate) mod cache;
|
||||
|
||||
#[cfg(test)]
|
||||
mod captures;
|
||||
pub mod element;
|
||||
pub mod element_properties;
|
||||
pub mod properties;
|
||||
pub mod property_ids;
|
||||
pub mod property_outcome;
|
||||
pub mod walker;
|
||||
pub(crate) mod walker_enumerate;
|
||||
pub mod walker_source;
|
||||
|
||||
#[cfg(test)]
|
||||
mod walker_fake;
|
||||
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod fixture;
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod fixture_window;
|
||||
|
|
|
|||
209
crates/windows/src/tree/properties.rs
Normal file
209
crates/windows/src/tree/properties.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
use agent_desktop_core::AdapterError;
|
||||
|
||||
use super::property_ids::TreeProperty;
|
||||
|
||||
pub use super::element_properties::ElementProperties;
|
||||
pub use super::property_outcome::{MAX_EVIDENCE_CHARS, PropertyOutcome, PropertyValue};
|
||||
|
||||
/// Bounds a string read and reports whether it survived intact.
|
||||
///
|
||||
/// A value longer than the bound is not truncated into evidence; the caller
|
||||
/// marks it `Unknown`.
|
||||
pub fn bounded_text(value: String) -> PropertyOutcome {
|
||||
if value.chars().count() > MAX_EVIDENCE_CHARS {
|
||||
PropertyOutcome::Unknown
|
||||
} else {
|
||||
PropertyOutcome::Known(PropertyValue::Text(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures one side of a rectangle.
|
||||
///
|
||||
/// Saturating, because a provider is free to hand back nonsense and this crate
|
||||
/// denies panicking paths: `right - left` on extreme values overflows `i32` and
|
||||
/// would abort a debug build. An inverted side is degenerate, not
|
||||
/// negative-sized, so it collapses to zero - the same shape A14-8 measured on a
|
||||
/// minimized top-level window - rather than travelling downstream as a negative
|
||||
/// width no consumer expects.
|
||||
pub fn extent(near: i32, far: i32) -> f64 {
|
||||
f64::from(far.saturating_sub(near).max(0))
|
||||
}
|
||||
|
||||
/// Builds the structured error for a property read that failed, carrying the
|
||||
/// property's name and never its value (KTD14).
|
||||
pub fn property_read_error(base: AdapterError, property: TreeProperty) -> AdapterError {
|
||||
base.with_details(serde_json::json!({
|
||||
"kind": "property_read_failed",
|
||||
"property": property.as_str(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
use super::{
|
||||
ElementProperties, PropertyOutcome, PropertyValue, TreeProperty, bounded_text,
|
||||
property_read_error,
|
||||
};
|
||||
use crate::tree::automation::{failure_of, uia_failure_error};
|
||||
use crate::tree::element::UIAElement;
|
||||
use crate::tree::property_ids::uia_property;
|
||||
use agent_desktop_core::{AdapterError, Rect};
|
||||
use uiautomation::Error as UiaError;
|
||||
use uiautomation::variants::{Value, Variant};
|
||||
use windows::Win32::UI::Accessibility::UiaGetReservedNotSupportedValue;
|
||||
use windows::core::{IUnknown, Interface};
|
||||
|
||||
/// Reads the walk property set from one element, live.
|
||||
///
|
||||
/// Errors are collected rather than propagated: a single failed property
|
||||
/// makes that slot `Unknown`, it does not abandon the element. The
|
||||
/// returned errors carry shape only and feed the walk's incompleteness
|
||||
/// accounting.
|
||||
pub fn read_live(element: &UIAElement) -> (ElementProperties, Vec<AdapterError>) {
|
||||
read_with(element, live_read)
|
||||
}
|
||||
|
||||
/// Reads the walk property set from an element's cache.
|
||||
///
|
||||
/// A property absent from the cache request is a programming error, not a
|
||||
/// live fetch: it classifies `Unknown` with a structured error, never
|
||||
/// `Absent`, so a missing request entry cannot masquerade as a provider
|
||||
/// that does not implement the property.
|
||||
pub fn read_cached(element: &UIAElement) -> (ElementProperties, Vec<AdapterError>) {
|
||||
read_with(element, cached_read)
|
||||
}
|
||||
|
||||
/// Reads one property live, for the cache policy's provider-class probe
|
||||
/// and for tests.
|
||||
pub fn read_one(element: &UIAElement, property: TreeProperty) -> PropertyOutcome {
|
||||
live_read(element, property).unwrap_or(PropertyOutcome::Unknown)
|
||||
}
|
||||
|
||||
fn live_read(
|
||||
element: &UIAElement,
|
||||
property: TreeProperty,
|
||||
) -> Result<PropertyOutcome, AdapterError> {
|
||||
if property == TreeProperty::BoundingRectangle {
|
||||
return element
|
||||
.0
|
||||
.get_bounding_rectangle()
|
||||
.map(rect_outcome)
|
||||
.map_err(|error| failed(&error, property, "read an element property"));
|
||||
}
|
||||
element
|
||||
.0
|
||||
.get_property_value(uia_property(property))
|
||||
.map(|variant| classify(&variant))
|
||||
.map_err(|error| failed(&error, property, "read an element property"))
|
||||
}
|
||||
|
||||
fn cached_read(
|
||||
element: &UIAElement,
|
||||
property: TreeProperty,
|
||||
) -> Result<PropertyOutcome, AdapterError> {
|
||||
if property == TreeProperty::BoundingRectangle {
|
||||
return element
|
||||
.0
|
||||
.get_cached_bounding_rectangle()
|
||||
.map(rect_outcome)
|
||||
.map_err(|error| failed(&error, property, "read a cached element property"));
|
||||
}
|
||||
element
|
||||
.0
|
||||
.get_cached_property_value(uia_property(property))
|
||||
.map(|variant| classify(&variant))
|
||||
.map_err(|error| failed(&error, property, "read a cached element property"))
|
||||
}
|
||||
|
||||
fn failed(error: &UiaError, property: TreeProperty, context: &str) -> AdapterError {
|
||||
property_read_error(uia_failure_error(failure_of(error), context), property)
|
||||
}
|
||||
|
||||
fn read_with(
|
||||
element: &UIAElement,
|
||||
read: impl Fn(&UIAElement, TreeProperty) -> Result<PropertyOutcome, AdapterError>,
|
||||
) -> (ElementProperties, Vec<AdapterError>) {
|
||||
let mut reads = Vec::with_capacity(TreeProperty::WALK_SET.len());
|
||||
let mut errors = Vec::new();
|
||||
for property in TreeProperty::WALK_SET {
|
||||
match read(element, property) {
|
||||
Ok(outcome) => reads.push((property, outcome)),
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
reads.push((property, PropertyOutcome::Unknown));
|
||||
}
|
||||
}
|
||||
}
|
||||
(ElementProperties::from_reads(reads), errors)
|
||||
}
|
||||
|
||||
/// Converts a UI Automation rectangle into core's.
|
||||
///
|
||||
/// An inverted rectangle - `right` left of `left` - is degenerate, not
|
||||
/// negative-sized. It collapses to zero extent, which is the same shape a
|
||||
/// minimized top-level window reports (A14-8), rather than travelling
|
||||
/// downstream as a negative width that no consumer expects.
|
||||
fn rect_outcome(rectangle: uiautomation::types::Rect) -> PropertyOutcome {
|
||||
PropertyOutcome::Known(PropertyValue::Bounds(Rect {
|
||||
x: f64::from(rectangle.get_left()),
|
||||
y: f64::from(rectangle.get_top()),
|
||||
width: super::extent(rectangle.get_left(), rectangle.get_right()),
|
||||
height: super::extent(rectangle.get_top(), rectangle.get_bottom()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Turns one variant into the tri-state.
|
||||
///
|
||||
/// The not-supported sentinel is compared by **pointer identity**, which
|
||||
/// is the only reliable test: it arrives as `VT_UNKNOWN` carrying the
|
||||
/// singleton `UiaGetReservedNotSupportedValue` returns, and no field of
|
||||
/// the variant distinguishes it from any other interface pointer.
|
||||
/// `VT_EMPTY`, `VT_NULL` and `VT_VOID` mean the provider answered with
|
||||
/// nothing, which is `Absent`; a variant this sub-phase cannot decode is
|
||||
/// `Unknown`, because presenting an undecoded value as identity evidence
|
||||
/// is what silently breaks re-identification downstream.
|
||||
fn classify(variant: &Variant) -> PropertyOutcome {
|
||||
if is_not_supported(variant) {
|
||||
return PropertyOutcome::Absent;
|
||||
}
|
||||
match variant.get_value() {
|
||||
Err(_) => PropertyOutcome::Unknown,
|
||||
Ok(Value::EMPTY | Value::NULL | Value::VOID) => PropertyOutcome::Absent,
|
||||
Ok(Value::STRING(text)) => bounded_text(text),
|
||||
Ok(Value::BOOL(flag)) => PropertyOutcome::Known(PropertyValue::Flag(flag)),
|
||||
Ok(Value::I4(number) | Value::INT(number)) => {
|
||||
PropertyOutcome::Known(PropertyValue::Number(number))
|
||||
}
|
||||
Ok(_) => PropertyOutcome::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The not-supported sentinel, fetched once per thread.
|
||||
///
|
||||
/// UI Automation documents it as a process singleton, so its address
|
||||
/// is stable and the comparison below is a pointer test. Fetching it
|
||||
/// per property per element turned a constant into an FFI call on the
|
||||
/// hottest path in the walk.
|
||||
static NOT_SUPPORTED: Option<IUnknown> =
|
||||
unsafe { UiaGetReservedNotSupportedValue() }.ok();
|
||||
}
|
||||
|
||||
fn is_not_supported(variant: &Variant) -> bool {
|
||||
let Ok(Value::UNKNOWN(candidate)) = variant.get_value() else {
|
||||
return false;
|
||||
};
|
||||
NOT_SUPPORTED.with(|sentinel| {
|
||||
sentinel
|
||||
.as_ref()
|
||||
.is_some_and(|sentinel| candidate.as_raw() == sentinel.as_raw())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use imp::{read_cached, read_live, read_one};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "properties_tests.rs"]
|
||||
mod tests;
|
||||
219
crates/windows/src/tree/properties_live_tests.rs
Normal file
219
crates/windows/src/tree/properties_live_tests.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
use super::*;
|
||||
use crate::tree::fixture::bootstrap;
|
||||
use crate::tree::fixture::{CONTENT_MARKER, HostedFixture, SECURE_MARKER};
|
||||
use crate::tree::properties::read_cached;
|
||||
use uiautomation::UIElement;
|
||||
|
||||
fn walk_children(handle: isize) -> Vec<crate::tree::element::UIAElement> {
|
||||
let root = crate::tree::automation::root_from_hwnd(
|
||||
handle,
|
||||
agent_desktop_core::Deadline::standard().expect("a standard deadline"),
|
||||
)
|
||||
.expect("the fixture window resolves");
|
||||
let client = crate::tree::automation::automation_client().expect("a UIA client");
|
||||
let walker = client
|
||||
.get_raw_view_walker()
|
||||
.expect("the raw view walker is available");
|
||||
let mut children: Vec<UIElement> = Vec::new();
|
||||
if let Ok(mut current) = walker.get_first_child(&root.0) {
|
||||
loop {
|
||||
let next = walker.get_next_sibling(¤t);
|
||||
children.push(current);
|
||||
match next {
|
||||
Ok(sibling) => current = sibling,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
children
|
||||
.into_iter()
|
||||
.map(crate::tree::element::UIAElement::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The gate asserted against a real out-of-process provider, not only
|
||||
/// against the projection: text typed into the fixture's `ES_PASSWORD`
|
||||
/// control must appear in no read outcome for `Value`, `Name` or
|
||||
/// `HelpText`.
|
||||
#[test]
|
||||
fn secure_content_never_reaches_a_read_outcome_from_a_live_provider() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let children = walk_children(fixture.handle());
|
||||
|
||||
let secure = children
|
||||
.iter()
|
||||
.map(|child| read_live(child).0)
|
||||
.find(ElementProperties::is_secure)
|
||||
.expect("the fixture exposes a control reporting IsPassword");
|
||||
|
||||
for property in TreeProperty::VALUE_BEARING {
|
||||
let rendered = format!("{:?}", secure.get(property));
|
||||
assert!(
|
||||
!rendered.contains(SECURE_MARKER),
|
||||
"{} leaked secure content",
|
||||
property.as_str()
|
||||
);
|
||||
assert_eq!(secure.get(property), PropertyOutcome::Absent);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_read_distinguishes_present_content_from_an_unimplemented_property() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let children = walk_children(fixture.handle());
|
||||
let all: Vec<ElementProperties> = children.iter().map(|child| read_live(child).0).collect();
|
||||
|
||||
assert!(
|
||||
all.iter().any(|properties| matches!(
|
||||
properties.get(TreeProperty::Value),
|
||||
PropertyOutcome::Known(PropertyValue::Text(ref value)) if value.contains(CONTENT_MARKER)
|
||||
)),
|
||||
"a plain control's content must read back as Known"
|
||||
);
|
||||
assert!(
|
||||
all.iter().any(|properties| matches!(
|
||||
properties.get(TreeProperty::AutomationId),
|
||||
PropertyOutcome::Known(_) | PropertyOutcome::Absent
|
||||
)),
|
||||
"an unimplemented property must classify Absent, never Unknown"
|
||||
);
|
||||
assert!(
|
||||
all.iter().all(|properties| !matches!(
|
||||
properties.get(TreeProperty::ClassName),
|
||||
PropertyOutcome::Unknown
|
||||
)),
|
||||
"a readable property must never classify Unknown on a healthy provider"
|
||||
);
|
||||
}
|
||||
|
||||
/// A14-9: once the host process exits, the client-side HWND proxy answers
|
||||
/// property reads locally with an empty string rather than failing, so
|
||||
/// process death is no more detectable through a property read than
|
||||
/// through the sibling terminator (A14-4).
|
||||
///
|
||||
/// The guarantee 2.2 does make is asserted instead, and it is the one
|
||||
/// that matters downstream: a provider that went away is never reported
|
||||
/// as a provider that does not implement the property. `Absent` feeds
|
||||
/// completeness gating as a legitimate answer; fabricating it here would
|
||||
/// let a dead target satisfy `EvidenceRequirements` it never answered.
|
||||
#[test]
|
||||
fn a_read_after_the_provider_exits_never_fabricates_absent() {
|
||||
bootstrap();
|
||||
let mut fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let children = walk_children(fixture.handle());
|
||||
assert!(!children.is_empty(), "the fixture exposes child controls");
|
||||
|
||||
fixture.terminate();
|
||||
std::thread::sleep(std::time::Duration::from_millis(750));
|
||||
|
||||
let (properties, _) = read_live(&children[0]);
|
||||
|
||||
for property in [
|
||||
TreeProperty::ClassName,
|
||||
TreeProperty::Name,
|
||||
TreeProperty::Value,
|
||||
] {
|
||||
assert_ne!(
|
||||
properties.get(property),
|
||||
PropertyOutcome::Absent,
|
||||
"a provider that went away must not be reported as not implementing {}",
|
||||
property.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The failure branch, forced deterministically rather than by killing a
|
||||
/// process: a property the cache request never carried cannot be answered,
|
||||
/// and must classify `Unknown` with a structured error.
|
||||
#[test]
|
||||
fn a_read_that_genuinely_fails_classifies_unknown_and_never_absent() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let root = crate::tree::automation::root_from_hwnd(
|
||||
fixture.handle(),
|
||||
agent_desktop_core::Deadline::standard().expect("a standard deadline"),
|
||||
)
|
||||
.expect("the fixture window resolves");
|
||||
let client = crate::tree::automation::automation_client().expect("a UIA client");
|
||||
let request = client
|
||||
.create_cache_request()
|
||||
.expect("an empty cache request builds");
|
||||
request
|
||||
.set_element_mode(uiautomation::types::ElementMode::Full)
|
||||
.expect("the element mode is settable");
|
||||
let sparse = root
|
||||
.0
|
||||
.build_updated_cache(&request)
|
||||
.map(crate::tree::element::UIAElement::from)
|
||||
.expect("an element with an empty cache");
|
||||
|
||||
let (properties, errors) = read_cached(&sparse);
|
||||
|
||||
assert!(
|
||||
!errors.is_empty(),
|
||||
"a read that cannot be answered must surface a structured error"
|
||||
);
|
||||
assert_eq!(
|
||||
properties.get(TreeProperty::ClassName),
|
||||
PropertyOutcome::Unknown
|
||||
);
|
||||
assert_ne!(
|
||||
properties.get(TreeProperty::ClassName),
|
||||
PropertyOutcome::Absent
|
||||
);
|
||||
}
|
||||
|
||||
/// The plan's U5 scenario, against a live provider rather than a synthetic
|
||||
/// error: a failed read on a control whose text *is* a unique marker must
|
||||
/// produce an error carrying none of it.
|
||||
///
|
||||
/// The failure is forced deterministically with an empty cache request, so
|
||||
/// the element is real, its text is real, and the read genuinely cannot be
|
||||
/// answered. `ref_action.rs` clones message and details into session JSONL
|
||||
/// and the trace HTML export, so a leak here is persisted, not transient.
|
||||
#[test]
|
||||
fn a_failed_read_on_a_marker_bearing_control_leaks_none_of_it() {
|
||||
bootstrap();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let client = crate::tree::automation::automation_client().expect("a UIA client");
|
||||
let request = client
|
||||
.create_cache_request()
|
||||
.expect("an empty cache request builds");
|
||||
request
|
||||
.set_element_mode(uiautomation::types::ElementMode::Full)
|
||||
.expect("the element mode is settable");
|
||||
|
||||
let marked = walk_children(fixture.handle())
|
||||
.into_iter()
|
||||
.find(|child| {
|
||||
matches!(
|
||||
read_live(child).0.get(TreeProperty::Value),
|
||||
PropertyOutcome::Known(PropertyValue::Text(ref value))
|
||||
if value.contains(CONTENT_MARKER)
|
||||
)
|
||||
})
|
||||
.expect("the fixture exposes a control carrying the marker");
|
||||
let uncacheable = marked
|
||||
.0
|
||||
.build_updated_cache(&request)
|
||||
.map(crate::tree::element::UIAElement::from)
|
||||
.expect("the same element with an empty cache");
|
||||
|
||||
let (_, errors) = read_cached(&uncacheable);
|
||||
|
||||
assert!(!errors.is_empty(), "the read must genuinely have failed");
|
||||
for error in errors {
|
||||
let rendered = format!(
|
||||
"{}|{}|{}",
|
||||
error.message,
|
||||
error.platform_detail.unwrap_or_default(),
|
||||
serde_json::to_string(&error.details).unwrap_or_default()
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains(CONTENT_MARKER),
|
||||
"a failed read leaked the control's text: {rendered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
292
crates/windows/src/tree/properties_tests.rs
Normal file
292
crates/windows/src/tree/properties_tests.rs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
use super::*;
|
||||
use agent_desktop_core::{IdentifierKind, LocatorField, Rect};
|
||||
|
||||
fn text(value: &str) -> PropertyOutcome {
|
||||
PropertyOutcome::Known(PropertyValue::Text(value.into()))
|
||||
}
|
||||
|
||||
fn reads(entries: &[(TreeProperty, PropertyOutcome)]) -> ElementProperties {
|
||||
ElementProperties::from_reads(entries.to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_property_the_provider_does_not_implement_is_absent_not_unknown() {
|
||||
let properties = reads(&[(TreeProperty::Name, PropertyOutcome::Absent)]);
|
||||
|
||||
assert_eq!(
|
||||
properties.get(TreeProperty::Name).text(),
|
||||
LocatorField::Absent
|
||||
);
|
||||
assert!(!properties.get(TreeProperty::Name).text().is_unknown());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_property_whose_read_failed_is_unknown_not_absent() {
|
||||
let properties = reads(&[(TreeProperty::Name, PropertyOutcome::Unknown)]);
|
||||
|
||||
assert_eq!(
|
||||
properties.get(TreeProperty::Name).text(),
|
||||
LocatorField::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_but_present_string_stays_known() {
|
||||
let properties = reads(&[(TreeProperty::Name, text(""))]);
|
||||
|
||||
assert_eq!(
|
||||
properties.get(TreeProperty::Name).text(),
|
||||
LocatorField::Known(String::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_string_past_the_bound_is_unknown_and_is_never_truncated_into_evidence() {
|
||||
let oversized = "x".repeat(MAX_EVIDENCE_CHARS + 1);
|
||||
|
||||
let outcome = bounded_text(oversized.clone());
|
||||
|
||||
assert_eq!(outcome, PropertyOutcome::Unknown);
|
||||
assert_eq!(outcome.text(), LocatorField::Unknown);
|
||||
assert_eq!(
|
||||
bounded_text("x".repeat(MAX_EVIDENCE_CHARS)).text(),
|
||||
LocatorField::Known("x".repeat(MAX_EVIDENCE_CHARS))
|
||||
);
|
||||
}
|
||||
|
||||
/// KTD13's gate, asserted on the projection rather than on a provider: every
|
||||
/// value-bearing property is withheld when `IsPassword` is true, and nothing
|
||||
/// else is.
|
||||
#[test]
|
||||
fn a_secure_element_withholds_every_value_bearing_property() {
|
||||
let properties = reads(&[
|
||||
(
|
||||
TreeProperty::IsPassword,
|
||||
PropertyOutcome::Known(PropertyValue::Flag(true)),
|
||||
),
|
||||
(TreeProperty::Name, text("secret-name")),
|
||||
(TreeProperty::Value, text("secret-value")),
|
||||
(TreeProperty::HelpText, text("secret-help")),
|
||||
(TreeProperty::LegacyValue, text("secret-legacy")),
|
||||
(TreeProperty::AutomationId, text("field-1")),
|
||||
(TreeProperty::ClassName, text("Edit")),
|
||||
]);
|
||||
|
||||
assert!(properties.is_secure());
|
||||
for property in TreeProperty::VALUE_BEARING {
|
||||
assert_eq!(
|
||||
properties.get(property),
|
||||
PropertyOutcome::Absent,
|
||||
"{} leaked through a secure element",
|
||||
property.as_str()
|
||||
);
|
||||
}
|
||||
assert_eq!(properties.get(TreeProperty::AutomationId), text("field-1"));
|
||||
assert_eq!(properties.get(TreeProperty::ClassName), text("Edit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_secure_element_keeps_every_value_bearing_property() {
|
||||
let properties = reads(&[
|
||||
(
|
||||
TreeProperty::IsPassword,
|
||||
PropertyOutcome::Known(PropertyValue::Flag(false)),
|
||||
),
|
||||
(TreeProperty::Name, text("visible-name")),
|
||||
(TreeProperty::Value, text("visible-value")),
|
||||
]);
|
||||
|
||||
assert!(!properties.is_secure());
|
||||
assert_eq!(properties.get(TreeProperty::Name), text("visible-name"));
|
||||
assert_eq!(properties.get(TreeProperty::Value), text("visible-value"));
|
||||
}
|
||||
|
||||
/// The gate fails closed. A failed `IsPassword` read is not evidence the
|
||||
/// element is safe to publish, and the costs are asymmetric: withholding a
|
||||
/// name that was not secret loses a little evidence, publishing one that was
|
||||
/// puts a password into a snapshot, a session JSONL segment and a trace HTML
|
||||
/// export.
|
||||
#[test]
|
||||
fn an_unreadable_is_password_withholds_rather_than_publishing() {
|
||||
let properties = reads(&[
|
||||
(TreeProperty::IsPassword, PropertyOutcome::Unknown),
|
||||
(TreeProperty::Value, text("visible-value")),
|
||||
]);
|
||||
|
||||
assert!(properties.is_secure());
|
||||
assert_eq!(properties.get(TreeProperty::Value), PropertyOutcome::Absent);
|
||||
}
|
||||
|
||||
/// Withholding replaces content, it does not invent an answer. When the whole
|
||||
/// read failed there is nothing to withhold, and rewriting `Unknown` to
|
||||
/// `Absent` would claim the provider does not implement the property — the
|
||||
/// fabrication A14-9 forbids, because `Absent` satisfies
|
||||
/// `EvidenceRequirements` and a target that never answered must not.
|
||||
#[test]
|
||||
fn withholding_never_turns_a_failed_read_into_a_real_answer() {
|
||||
let properties = reads(&[
|
||||
(TreeProperty::IsPassword, PropertyOutcome::Unknown),
|
||||
(TreeProperty::Value, PropertyOutcome::Unknown),
|
||||
(TreeProperty::Name, text("readable-name")),
|
||||
]);
|
||||
|
||||
assert!(properties.is_secure());
|
||||
assert_eq!(
|
||||
properties.get(TreeProperty::Value),
|
||||
PropertyOutcome::Unknown,
|
||||
"a failed read has no content to withhold"
|
||||
);
|
||||
assert_eq!(
|
||||
properties.get(TreeProperty::Name),
|
||||
PropertyOutcome::Absent,
|
||||
"content that was actually read is withheld"
|
||||
);
|
||||
}
|
||||
|
||||
/// A provider that answers `IsPassword` as an integer where UIA documents a
|
||||
/// boolean must not slip past the gate. Reading only `VT_BOOL` would let it.
|
||||
#[test]
|
||||
fn an_integer_is_password_still_closes_the_gate() {
|
||||
let properties = reads(&[
|
||||
(
|
||||
TreeProperty::IsPassword,
|
||||
PropertyOutcome::Known(PropertyValue::Number(1)),
|
||||
),
|
||||
(TreeProperty::Value, text("secret-value")),
|
||||
]);
|
||||
|
||||
assert!(properties.is_secure());
|
||||
assert_eq!(properties.get(TreeProperty::Value), PropertyOutcome::Absent);
|
||||
}
|
||||
|
||||
/// A provider that answers `IsPassword` and does not implement it has given a
|
||||
/// real answer, so the gate stays open - otherwise every element on a provider
|
||||
/// without the property would lose its name and value.
|
||||
#[test]
|
||||
fn an_absent_is_password_is_a_real_answer_and_leaves_content_readable() {
|
||||
let properties = reads(&[
|
||||
(TreeProperty::IsPassword, PropertyOutcome::Absent),
|
||||
(TreeProperty::Value, text("visible-value")),
|
||||
]);
|
||||
|
||||
assert!(!properties.is_secure());
|
||||
assert_eq!(properties.get(TreeProperty::Value), text("visible-value"));
|
||||
}
|
||||
|
||||
/// An element whose read set never asked for `IsPassword` is not gated: there
|
||||
/// was nothing to gate, and the walk chooses the property set.
|
||||
#[test]
|
||||
fn a_read_set_without_the_flag_is_not_gated() {
|
||||
let properties = reads(&[(TreeProperty::Value, text("visible-value"))]);
|
||||
|
||||
assert!(!properties.is_secure());
|
||||
assert_eq!(properties.get(TreeProperty::Value), text("visible-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_automation_id_is_complete_evidence_and_a_failed_read_is_not() {
|
||||
let absent = reads(&[(TreeProperty::AutomationId, PropertyOutcome::Absent)])
|
||||
.into_locator_evidence(LocatorField::Unknown, LocatorField::Unknown);
|
||||
let failed = reads(&[(TreeProperty::AutomationId, PropertyOutcome::Unknown)])
|
||||
.into_locator_evidence(LocatorField::Unknown, LocatorField::Unknown);
|
||||
|
||||
assert!(absent.identifiers.is_complete());
|
||||
assert!(!failed.identifiers.is_complete());
|
||||
}
|
||||
|
||||
/// `IdentifierEvidence::new` stamps `IdentifierKind::Unknown`, which
|
||||
/// `refs_validate.rs` treats as no identifier at all; the reader must use
|
||||
/// `typed`.
|
||||
#[test]
|
||||
fn an_automation_id_is_carried_as_a_typed_identifier() {
|
||||
let evidence = reads(&[(TreeProperty::AutomationId, text("save-button"))])
|
||||
.into_locator_evidence(LocatorField::Unknown, LocatorField::Unknown);
|
||||
|
||||
let identifier = evidence
|
||||
.identifiers
|
||||
.preferred_identifier()
|
||||
.expect("a known automation id is the preferred identifier");
|
||||
assert_eq!(identifier.kind, IdentifierKind::AutomationId);
|
||||
assert_eq!(identifier.value, "save-button");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whitespace_only_automation_id_is_not_promoted_to_an_identifier() {
|
||||
let evidence = reads(&[(TreeProperty::AutomationId, text(" "))])
|
||||
.into_locator_evidence(LocatorField::Unknown, LocatorField::Unknown);
|
||||
|
||||
assert!(evidence.identifiers.preferred_identifier().is_none());
|
||||
assert!(evidence.identifiers.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_evidence_projection_fills_every_slot_the_walk_owns() {
|
||||
let evidence = reads(&[
|
||||
(TreeProperty::Name, text("Save")),
|
||||
(TreeProperty::Value, text("draft")),
|
||||
(TreeProperty::HelpText, PropertyOutcome::Absent),
|
||||
(
|
||||
TreeProperty::BoundingRectangle,
|
||||
PropertyOutcome::Known(PropertyValue::Bounds(Rect {
|
||||
x: 1.0,
|
||||
y: 2.0,
|
||||
width: 3.0,
|
||||
height: 4.0,
|
||||
})),
|
||||
),
|
||||
])
|
||||
.into_locator_evidence(
|
||||
LocatorField::Known("button".into()),
|
||||
LocatorField::Known(Vec::new()),
|
||||
);
|
||||
|
||||
assert_eq!(evidence.name, LocatorField::Known("Save".into()));
|
||||
assert_eq!(evidence.value, LocatorField::Known("draft".into()));
|
||||
assert_eq!(evidence.description, LocatorField::Absent);
|
||||
assert_eq!(evidence.role, LocatorField::Known("button".into()));
|
||||
assert!(!evidence.ref_evidence.bounds.is_unknown());
|
||||
assert!(evidence.states.is_unknown());
|
||||
}
|
||||
|
||||
/// KTD14: a failed read must name the property and never carry its content.
|
||||
/// A provider is free to hand back nonsense. An inverted side is degenerate,
|
||||
/// not negative-sized, and an extreme one must not overflow a debug build.
|
||||
#[test]
|
||||
fn a_rectangle_side_is_never_negative_and_never_overflows() {
|
||||
assert_eq!(extent(10, 40), 30.0);
|
||||
assert_eq!(extent(40, 10), 0.0, "an inverted side collapses to zero");
|
||||
assert_eq!(extent(7, 7), 0.0);
|
||||
assert_eq!(
|
||||
extent(i32::MIN, i32::MAX),
|
||||
f64::from(i32::MAX),
|
||||
"an extreme span saturates rather than overflowing"
|
||||
);
|
||||
assert_eq!(extent(i32::MAX, i32::MIN), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_property_read_error_names_the_property_and_carries_no_content() {
|
||||
const MARKER: &str = "zzmarkerzz-account-number";
|
||||
let base = crate::tree::automation::uia_failure_error(
|
||||
crate::tree::automation::UiaFailure::Sentinel(crate::tree::automation::ERR_TIMEOUT),
|
||||
"read an element property",
|
||||
);
|
||||
|
||||
let error = property_read_error(base, TreeProperty::Value);
|
||||
|
||||
let rendered = format!(
|
||||
"{}|{}|{}",
|
||||
error.message,
|
||||
error.platform_detail.clone().unwrap_or_default(),
|
||||
serde_json::to_string(&error.details).unwrap_or_default()
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Value"),
|
||||
"the error must name the property"
|
||||
);
|
||||
assert!(!rendered.contains(MARKER), "leaked: {rendered}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "properties_live_tests.rs"]
|
||||
mod windows_only;
|
||||
108
crates/windows/src/tree/property_ids.rs
Normal file
108
crates/windows/src/tree/property_ids.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/// The properties sub-phase 2.2 reads, named independently of the UI
|
||||
/// Automation numbering.
|
||||
///
|
||||
/// The numbering is deliberately absent here. A2-5 measured that UIA property
|
||||
/// ids are build-specific - `IsAnnotationPatternAvailable` is 30118 on build
|
||||
/// 17763 while 30113 is a different property - and named 2.2 as the place a
|
||||
/// hand-written table would fail silently. Every id comes from the crate's
|
||||
/// generated constants through the mapping below.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TreeProperty {
|
||||
Name,
|
||||
AutomationId,
|
||||
ClassName,
|
||||
HelpText,
|
||||
Value,
|
||||
LegacyValue,
|
||||
BoundingRectangle,
|
||||
IsPassword,
|
||||
IsOffscreen,
|
||||
IsEnabled,
|
||||
ProviderDescription,
|
||||
ControlType,
|
||||
RuntimeId,
|
||||
}
|
||||
|
||||
impl TreeProperty {
|
||||
/// Every property whose content originates in the target and must
|
||||
/// therefore be withheld from a control reporting `IsPassword`.
|
||||
pub const VALUE_BEARING: [TreeProperty; 4] = [
|
||||
TreeProperty::Name,
|
||||
TreeProperty::HelpText,
|
||||
TreeProperty::Value,
|
||||
TreeProperty::LegacyValue,
|
||||
];
|
||||
|
||||
/// The set a subtree walk reads, and therefore the set a cache request
|
||||
/// carries. Nothing else is requested, so nothing unread is paid for.
|
||||
pub const WALK_SET: [TreeProperty; 10] = [
|
||||
TreeProperty::Name,
|
||||
TreeProperty::AutomationId,
|
||||
TreeProperty::ClassName,
|
||||
TreeProperty::HelpText,
|
||||
TreeProperty::Value,
|
||||
TreeProperty::LegacyValue,
|
||||
TreeProperty::BoundingRectangle,
|
||||
TreeProperty::IsPassword,
|
||||
TreeProperty::IsOffscreen,
|
||||
TreeProperty::IsEnabled,
|
||||
];
|
||||
|
||||
pub fn is_value_bearing(self) -> bool {
|
||||
Self::VALUE_BEARING.contains(&self)
|
||||
}
|
||||
|
||||
/// Names the property for a structured error without exposing its value.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Name => "Name",
|
||||
Self::AutomationId => "AutomationId",
|
||||
Self::ClassName => "ClassName",
|
||||
Self::HelpText => "HelpText",
|
||||
Self::Value => "Value",
|
||||
Self::LegacyValue => "LegacyIAccessibleValue",
|
||||
Self::BoundingRectangle => "BoundingRectangle",
|
||||
Self::IsPassword => "IsPassword",
|
||||
Self::IsOffscreen => "IsOffscreen",
|
||||
Self::IsEnabled => "IsEnabled",
|
||||
Self::ProviderDescription => "ProviderDescription",
|
||||
Self::ControlType => "ControlType",
|
||||
Self::RuntimeId => "RuntimeId",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
use super::TreeProperty;
|
||||
use uiautomation::types::UIProperty;
|
||||
|
||||
/// Resolves an internal property to the crate's generated constant.
|
||||
///
|
||||
/// An exhaustive `match` with no catch-all arm, so adding a variant is a
|
||||
/// compile error rather than a silent fallback to a wrong id.
|
||||
pub fn uia_property(property: TreeProperty) -> UIProperty {
|
||||
match property {
|
||||
TreeProperty::Name => UIProperty::Name,
|
||||
TreeProperty::AutomationId => UIProperty::AutomationId,
|
||||
TreeProperty::ClassName => UIProperty::ClassName,
|
||||
TreeProperty::HelpText => UIProperty::HelpText,
|
||||
TreeProperty::Value => UIProperty::ValueValue,
|
||||
TreeProperty::LegacyValue => UIProperty::LegacyIAccessibleValue,
|
||||
TreeProperty::BoundingRectangle => UIProperty::BoundingRectangle,
|
||||
TreeProperty::IsPassword => UIProperty::IsPassword,
|
||||
TreeProperty::IsOffscreen => UIProperty::IsOffscreen,
|
||||
TreeProperty::IsEnabled => UIProperty::IsEnabled,
|
||||
TreeProperty::ProviderDescription => UIProperty::ProviderDescription,
|
||||
TreeProperty::ControlType => UIProperty::ControlType,
|
||||
TreeProperty::RuntimeId => UIProperty::RuntimeId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use imp::uia_property;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "property_ids_tests.rs"]
|
||||
mod tests;
|
||||
109
crates/windows/src/tree/property_ids_tests.rs
Normal file
109
crates/windows/src/tree/property_ids_tests.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_value_bearing_property_is_in_the_walk_set() {
|
||||
for property in TreeProperty::VALUE_BEARING {
|
||||
assert!(
|
||||
TreeProperty::WALK_SET.contains(&property),
|
||||
"{} is gated but never read, so the gate would never fire",
|
||||
property.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate is only sound if `IsPassword` arrives in the same batch as the
|
||||
/// properties it gates; a separate read would cost the round trip KTD5 exists
|
||||
/// to avoid and would open a window where the gate has no input.
|
||||
#[test]
|
||||
fn the_walk_set_carries_the_flag_that_gates_it() {
|
||||
assert!(TreeProperty::WALK_SET.contains(&TreeProperty::IsPassword));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gate_covers_exactly_the_properties_whose_content_comes_from_the_target() {
|
||||
assert!(TreeProperty::Name.is_value_bearing());
|
||||
assert!(TreeProperty::Value.is_value_bearing());
|
||||
assert!(TreeProperty::HelpText.is_value_bearing());
|
||||
assert!(TreeProperty::LegacyValue.is_value_bearing());
|
||||
|
||||
assert!(!TreeProperty::AutomationId.is_value_bearing());
|
||||
assert!(!TreeProperty::ClassName.is_value_bearing());
|
||||
assert!(!TreeProperty::BoundingRectangle.is_value_bearing());
|
||||
assert!(!TreeProperty::IsPassword.is_value_bearing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_walk_set_has_no_duplicate_entries() {
|
||||
let mut seen = Vec::new();
|
||||
for property in TreeProperty::WALK_SET {
|
||||
assert!(
|
||||
!seen.contains(&property),
|
||||
"{} appears twice",
|
||||
property.as_str()
|
||||
);
|
||||
seen.push(property);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_property_names_itself_for_a_structured_error() {
|
||||
for property in TreeProperty::WALK_SET {
|
||||
assert!(!property.as_str().is_empty());
|
||||
}
|
||||
assert_eq!(TreeProperty::LegacyValue.as_str(), "LegacyIAccessibleValue");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn every_property_resolves_through_the_crate_generated_constants() {
|
||||
for property in TreeProperty::WALK_SET {
|
||||
let _ = uia_property(property);
|
||||
}
|
||||
let _ = uia_property(TreeProperty::ProviderDescription);
|
||||
let _ = uia_property(TreeProperty::ControlType);
|
||||
let _ = uia_property(TreeProperty::RuntimeId);
|
||||
}
|
||||
|
||||
/// A2-5 measured that UIA property ids are build-specific and named 2.2 as
|
||||
/// the place a hand-written table would fail silently, so the source must
|
||||
/// contain no bare property-id integer.
|
||||
///
|
||||
/// Matched as a whole token in the range UIA actually uses, not as the
|
||||
/// substring "300": a prose mention of 300 milliseconds, or a `30_000` ms
|
||||
/// constant, is not a property id and must not fail this.
|
||||
#[test]
|
||||
fn no_property_id_integer_appears_in_this_module() {
|
||||
for (name, source) in [
|
||||
("property_ids.rs", include_str!("property_ids.rs")),
|
||||
("properties.rs", include_str!("properties.rs")),
|
||||
("cache.rs", include_str!("cache.rs")),
|
||||
] {
|
||||
for (number, line) in source.lines().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("///") || trimmed.starts_with("//!") {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
!contains_property_id_literal(line),
|
||||
"{name}:{} carries a UIA property-id literal: {line}",
|
||||
number + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether a line contains a bare integer in UIA's property-id range.
|
||||
///
|
||||
/// A token is a candidate only when it is a whole number, five digits long,
|
||||
/// and between 30000 and 30999 - the block UIA allocates property ids from.
|
||||
fn contains_property_id_literal(line: &str) -> bool {
|
||||
line.split(|character: char| !(character.is_ascii_digit() || character == '_'))
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(|token| token.replace('_', ""))
|
||||
.any(|token| {
|
||||
token.len() == 5
|
||||
&& token
|
||||
.parse::<u32>()
|
||||
.is_ok_and(|value| (30_000..=30_999).contains(&value))
|
||||
})
|
||||
}
|
||||
66
crates/windows/src/tree/property_outcome.rs
Normal file
66
crates/windows/src/tree/property_outcome.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use agent_desktop_core::{LocatorField, Rect};
|
||||
|
||||
/// Longest string this sub-phase will carry into evidence.
|
||||
///
|
||||
/// A value past the bound is `Unknown` rather than a truncated `Known`: a
|
||||
/// prefix that is presented as exact identity evidence would make 2.5's
|
||||
/// re-identification match the wrong element.
|
||||
pub const MAX_EVIDENCE_CHARS: usize = 2_048;
|
||||
|
||||
/// One property read, in the three states core's `LocatorField` distinguishes.
|
||||
///
|
||||
/// UI Automation has no per-property error channel. macOS gets a parallel
|
||||
/// array where an absent slot is `kCFNull` and a failed slot carries its own
|
||||
/// error; UIA has neither, so this type is built by hand from the
|
||||
/// not-supported sentinel, the variant tag, and the call's own result.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PropertyOutcome {
|
||||
/// The provider answered with a value.
|
||||
Known(PropertyValue),
|
||||
/// The provider answered, and does not implement this property.
|
||||
Absent,
|
||||
/// The read failed, or its answer cannot be trusted as identity evidence.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PropertyValue {
|
||||
Text(String),
|
||||
Flag(bool),
|
||||
Number(i32),
|
||||
Bounds(Rect),
|
||||
}
|
||||
|
||||
impl PropertyOutcome {
|
||||
pub fn text(&self) -> LocatorField<String> {
|
||||
match self {
|
||||
Self::Known(PropertyValue::Text(value)) => LocatorField::Known(value.clone()),
|
||||
Self::Known(_) => LocatorField::Unknown,
|
||||
Self::Absent => LocatorField::Absent,
|
||||
Self::Unknown => LocatorField::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flag(&self) -> Option<bool> {
|
||||
match self {
|
||||
Self::Known(PropertyValue::Flag(value)) => Some(*value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn number(&self) -> Option<i32> {
|
||||
match self {
|
||||
Self::Known(PropertyValue::Number(value)) => Some(*value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bounds(&self) -> LocatorField<Rect> {
|
||||
match self {
|
||||
Self::Known(PropertyValue::Bounds(value)) => LocatorField::Known(*value),
|
||||
Self::Known(_) => LocatorField::Unknown,
|
||||
Self::Absent => LocatorField::Absent,
|
||||
Self::Unknown => LocatorField::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
180
crates/windows/src/tree/walker.rs
Normal file
180
crates/windows/src/tree/walker.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
use agent_desktop_core::{
|
||||
AdapterError, Deadline, ErrorCode, LocatorEvidence, LocatorField, LocatorStats,
|
||||
ObservationRoot, ObservationSource, ObservedTree,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::automation::UiaFailure;
|
||||
use super::element::UIAElement;
|
||||
use super::walker_enumerate::TreeWalk;
|
||||
|
||||
/// The deepest native nesting one walk descends through.
|
||||
///
|
||||
/// Matches core's own ceiling on `ObservationRequest::max_raw_depth`, so a
|
||||
/// request core accepts is never rejected here.
|
||||
pub const DEFAULT_MAX_RAW_DEPTH: u8 = 50;
|
||||
|
||||
/// The longest sibling list one parent contributes.
|
||||
///
|
||||
/// The ancestor cycle guard cannot see a sibling chain that never terminates,
|
||||
/// because such a chain visits no element twice on any root-to-node path. This
|
||||
/// bound is what makes the enumeration loop total; hitting it marks the subtree
|
||||
/// incomplete rather than presenting a short list as the whole child set.
|
||||
pub const DEFAULT_MAX_SIBLINGS: usize = 10_000;
|
||||
|
||||
/// Everything that bounds one walk.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WalkBudget {
|
||||
pub max_raw_depth: u8,
|
||||
pub max_logical_depth: u8,
|
||||
pub max_siblings: usize,
|
||||
pub deadline: Deadline,
|
||||
}
|
||||
|
||||
impl WalkBudget {
|
||||
pub fn new(max_logical_depth: u8, deadline: Deadline) -> Self {
|
||||
Self {
|
||||
max_raw_depth: DEFAULT_MAX_RAW_DEPTH,
|
||||
max_logical_depth,
|
||||
max_siblings: DEFAULT_MAX_SIBLINGS,
|
||||
deadline,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_max_raw_depth(mut self, max_raw_depth: u8) -> Self {
|
||||
self.max_raw_depth = max_raw_depth;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_siblings(mut self, max_siblings: usize) -> Self {
|
||||
self.max_siblings = max_siblings;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// What the cycle guard compares.
|
||||
///
|
||||
/// UI Automation hands back a *new* `IUIAutomationElement` proxy for every
|
||||
/// query, so pointer identity says nothing at all — the macOS guard's key does
|
||||
/// not transfer even though its ancestor-path semantics do. The runtime id is
|
||||
/// the identity UI Automation itself publishes; when a provider will not supply
|
||||
/// one, the guard falls back to asking the client to compare two elements.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NodeKey {
|
||||
Runtime(Vec<i32>),
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// The single enumeration surface the walk drives.
|
||||
///
|
||||
/// The real UI Automation walker and the in-memory fakes implement this same
|
||||
/// trait, so the correctness branches the fakes exercise are the branches that
|
||||
/// ship. A fake wired to a second traversal would test nothing.
|
||||
pub trait TreeSource {
|
||||
type Node: Clone;
|
||||
|
||||
/// Descends one level. `Err` whose failure `is_exhaustion()` means the
|
||||
/// element has no children.
|
||||
fn first_child(&self, node: &Self::Node) -> Result<Self::Node, UiaFailure>;
|
||||
|
||||
/// Advances along a sibling list. `Err` whose failure `is_exhaustion()`
|
||||
/// ends the list; anything else is a genuine fault.
|
||||
fn next_sibling(&self, node: &Self::Node) -> Result<Self::Node, UiaFailure>;
|
||||
|
||||
fn identity(&self, node: &Self::Node) -> NodeKey;
|
||||
|
||||
/// Compares two elements when neither carries a runtime id.
|
||||
fn same_element(&self, left: &Self::Node, right: &Self::Node) -> bool;
|
||||
|
||||
/// Reads this node's evidence, returning the count of reads that failed.
|
||||
fn evidence(&self, node: &Self::Node) -> (LocatorEvidence, u64);
|
||||
|
||||
fn is_web_wrapper(&self, node: &Self::Node) -> bool;
|
||||
}
|
||||
|
||||
/// One completed walk.
|
||||
///
|
||||
/// `failures` is populated when the tree is incomplete: a real enumeration
|
||||
/// fault produces an incomplete tree *and* a structured error, never a
|
||||
/// truncated success.
|
||||
pub struct WalkOutcome {
|
||||
pub tree: ObservedTree,
|
||||
pub stats: LocatorStats,
|
||||
pub failures: Vec<AdapterError>,
|
||||
}
|
||||
|
||||
/// Walks a subtree from an arbitrary root element.
|
||||
///
|
||||
/// The entry point is an element, not a window handle: `ElementFromHandle` is
|
||||
/// one root source among several, and drill-down from a stored ref must re-enter
|
||||
/// this same traversal rather than fork a second one.
|
||||
///
|
||||
/// No ref is allocated here. `agent_desktop_core::ref_alloc::allocate_refs` is
|
||||
/// the only allocator in the product.
|
||||
pub fn walk_from_root<S: TreeSource>(
|
||||
source: &S,
|
||||
root: &S::Node,
|
||||
root_source: &ObservationRoot<'_>,
|
||||
budget: WalkBudget,
|
||||
) -> Result<WalkOutcome, AdapterError> {
|
||||
let mut walk = TreeWalk::new(source, budget);
|
||||
let root_subtree = walk.visit(root.clone(), 0, 0);
|
||||
let (stats, structurally_complete, failures) = walk.finish()?;
|
||||
let root_subtree = root_subtree.ok_or_else(|| root_missing_error(&stats))?;
|
||||
let tree = ObservedTree::from_roots(
|
||||
vec![root_subtree],
|
||||
ObservationSource::from_root(root_source),
|
||||
stats.clone(),
|
||||
structurally_complete,
|
||||
)?;
|
||||
Ok(WalkOutcome {
|
||||
tree,
|
||||
stats,
|
||||
failures,
|
||||
})
|
||||
}
|
||||
|
||||
fn root_missing_error(stats: &LocatorStats) -> AdapterError {
|
||||
let code = if stats.reads.health.deadline_exhausted > 0 {
|
||||
ErrorCode::Timeout
|
||||
} else {
|
||||
ErrorCode::AppUnresponsive
|
||||
};
|
||||
AdapterError::new(code, "Element tree walk ended before reading its root")
|
||||
.with_suggestion("Retry with a larger timeout or a narrower depth")
|
||||
.with_details(json!({
|
||||
"kind": "walk_root_unread",
|
||||
"deadline_exhausted": stats.reads.health.deadline_exhausted,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether this element is a wrapper that costs raw depth but no logical depth.
|
||||
///
|
||||
/// Sub-phase 2.2 ships the seam, not the predicate: the body is 2.4's, together
|
||||
/// with Chromium detection. It is called where child logical depth is computed,
|
||||
/// so 2.4 fills it in without editing the traversal, and a fake can force the
|
||||
/// two counters apart today.
|
||||
pub fn is_web_wrapper(_element: &UIAElement) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The role vocabulary seam 2.3 fills.
|
||||
///
|
||||
/// `Unknown` is the honest answer while no vocabulary exists, and it is also
|
||||
/// the one that produces the documented projection: core renders an unknown
|
||||
/// role as the string `unknown`.
|
||||
pub fn walk_role(_element: &UIAElement) -> LocatorField<String> {
|
||||
LocatorField::Unknown
|
||||
}
|
||||
|
||||
/// The available-actions seam 2.3 fills.
|
||||
///
|
||||
/// `Unknown` projects to an empty action list in core, which is what an
|
||||
/// adapter with no pattern vocabulary can truthfully offer.
|
||||
pub fn walk_available_actions(_element: &UIAElement) -> LocatorField<Vec<String>> {
|
||||
LocatorField::Unknown
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "walker_tests.rs"]
|
||||
mod tests;
|
||||
331
crates/windows/src/tree/walker_enumerate.rs
Normal file
331
crates/windows/src/tree/walker_enumerate.rs
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
use agent_desktop_core::{AdapterError, LocatorStats, ObservedSubtree};
|
||||
use serde_json::json;
|
||||
|
||||
use super::automation::{UiaFailure, uia_failure_error};
|
||||
use super::walker::{NodeKey, TreeSource, WalkBudget};
|
||||
use crate::system::permissions::ensure_budget;
|
||||
|
||||
/// How many enumeration faults one walk reports.
|
||||
///
|
||||
/// A pathological target can fault on every node; the tree still records the
|
||||
/// incompleteness for all of them, while the error list stays bounded so a
|
||||
/// trace segment cannot be flooded from a single walk.
|
||||
const MAX_REPORTED_FAILURES: usize = 8;
|
||||
|
||||
/// Which of the two enumeration calls faulted.
|
||||
///
|
||||
/// Typed rather than a string: the two axes behave differently against a dead
|
||||
/// provider - descent surfaces `E_FAIL` while the sibling terminator is
|
||||
/// indistinguishable from end-of-list (A14-4) - so a reader of a trace segment
|
||||
/// needs to know which one this was, and a typo must not be able to change it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum EnumerationAxis {
|
||||
FirstChild,
|
||||
NextSibling,
|
||||
}
|
||||
|
||||
impl EnumerationAxis {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::FirstChild => "first_child",
|
||||
Self::NextSibling => "next_sibling",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One node's child list as the enumeration loop found it.
|
||||
struct ChildRead<N> {
|
||||
elements: Vec<N>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
/// The traversal state for one walk.
|
||||
///
|
||||
/// `ancestors` is an ancestor path, never a global visited set: a global set
|
||||
/// would prune a real sibling subtree that legitimately re-reaches the same
|
||||
/// element by a different route.
|
||||
pub struct TreeWalk<'a, S: TreeSource> {
|
||||
source: &'a S,
|
||||
budget: WalkBudget,
|
||||
ancestors: Vec<(NodeKey, S::Node)>,
|
||||
stats: LocatorStats,
|
||||
complete: bool,
|
||||
failures: Vec<AdapterError>,
|
||||
suppressed_failures: u32,
|
||||
}
|
||||
|
||||
impl<'a, S: TreeSource> TreeWalk<'a, S> {
|
||||
pub fn new(source: &'a S, budget: WalkBudget) -> Self {
|
||||
Self {
|
||||
source,
|
||||
budget,
|
||||
ancestors: Vec::new(),
|
||||
stats: LocatorStats::default(),
|
||||
complete: true,
|
||||
failures: Vec::new(),
|
||||
suppressed_failures: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many entries the ancestor path currently holds.
|
||||
///
|
||||
/// Zero on every exit from a completed `visit`, including the exits taken
|
||||
/// when enumeration faults. A missed removal is the bug this guard ships
|
||||
/// with most easily, so it is observable rather than implied.
|
||||
#[cfg(test)]
|
||||
pub fn ancestor_depth(&self) -> usize {
|
||||
self.ancestors.len()
|
||||
}
|
||||
|
||||
/// Visits one element and everything beneath it.
|
||||
///
|
||||
/// The ancestor entry is pushed once here and popped once here, so every
|
||||
/// path out of the visited body — normal, budget-exhausted, or faulting —
|
||||
/// leaves the path exactly as it found it.
|
||||
pub fn visit(
|
||||
&mut self,
|
||||
node: S::Node,
|
||||
logical_depth: u8,
|
||||
raw_depth: u8,
|
||||
) -> Option<ObservedSubtree> {
|
||||
if ensure_budget(self.budget.deadline).is_err() {
|
||||
self.note_deadline_exhausted();
|
||||
return None;
|
||||
}
|
||||
let key = self.source.identity(&node);
|
||||
if self.is_ancestor(&key, &node) {
|
||||
self.stats.traversal.cycles_skipped += 1;
|
||||
return None;
|
||||
}
|
||||
self.ancestors.push((key, node.clone()));
|
||||
let subtree = self.visit_entered(&node, logical_depth, raw_depth);
|
||||
self.ancestors.pop();
|
||||
subtree
|
||||
}
|
||||
|
||||
/// Reports the walk's accounting, refusing a run that leaked an ancestor
|
||||
/// entry rather than presenting its tree as trustworthy.
|
||||
pub fn finish(self) -> Result<(LocatorStats, bool, Vec<AdapterError>), AdapterError> {
|
||||
if !self.ancestors.is_empty() {
|
||||
return Err(AdapterError::internal(format!(
|
||||
"element tree walk retained {} ancestor entries",
|
||||
self.ancestors.len()
|
||||
)));
|
||||
}
|
||||
let mut failures = self.failures;
|
||||
if let Some(last) = failures.last_mut() {
|
||||
annotate_suppressed(last, self.suppressed_failures);
|
||||
}
|
||||
Ok((self.stats, self.complete, failures))
|
||||
}
|
||||
|
||||
fn visit_entered(
|
||||
&mut self,
|
||||
node: &S::Node,
|
||||
logical_depth: u8,
|
||||
raw_depth: u8,
|
||||
) -> Option<ObservedSubtree> {
|
||||
self.note_visit(logical_depth, raw_depth);
|
||||
let (evidence, read_failures) = self.source.evidence(node);
|
||||
self.stats.reads.counts.observation_attempts += 1;
|
||||
self.stats.reads.health.native_read_failures += read_failures;
|
||||
let web_wrapper = self.source.is_web_wrapper(node);
|
||||
if web_wrapper {
|
||||
self.stats.traversal.web_wrapper_nodes += 1;
|
||||
}
|
||||
let child_logical_depth = logical_depth.saturating_add(u8::from(!web_wrapper));
|
||||
if ensure_budget(self.budget.deadline).is_err() {
|
||||
self.note_deadline_exhausted();
|
||||
return None;
|
||||
}
|
||||
let read = self.read_children(node, raw_depth);
|
||||
let truncated_by_raw_depth =
|
||||
raw_depth >= self.budget.max_raw_depth && !read.elements.is_empty();
|
||||
let at_boundary =
|
||||
truncated_by_raw_depth || child_logical_depth > self.budget.max_logical_depth;
|
||||
let (children, children_count, subtree_complete) = if at_boundary {
|
||||
if truncated_by_raw_depth {
|
||||
self.stats.traversal.limits.depth_hits += 1;
|
||||
}
|
||||
let count = u32::try_from(read.elements.len()).ok().filter(|c| *c > 0);
|
||||
(Vec::new(), count, read.complete && !truncated_by_raw_depth)
|
||||
} else {
|
||||
let (children, complete) = self.visit_children(read, (child_logical_depth, raw_depth));
|
||||
(children, None, complete)
|
||||
};
|
||||
if !subtree_complete {
|
||||
self.complete = false;
|
||||
}
|
||||
Some(ObservedSubtree::new(
|
||||
evidence,
|
||||
children,
|
||||
subtree_complete,
|
||||
children_count,
|
||||
))
|
||||
}
|
||||
|
||||
/// The child enumeration loop.
|
||||
///
|
||||
/// Written directly on `first_child` / `next_sibling` because the crate's
|
||||
/// own `while let Ok(next) = self.next_sibling(¤t)` retires
|
||||
/// end-of-siblings and a cross-process RPC fault through the same arm,
|
||||
/// which turns a hung target into a truncated tree that reports complete.
|
||||
/// Every `Err` is classified on the measured pair instead: an exhaustion
|
||||
/// failure ends the list and leaves the subtree complete, anything else
|
||||
/// marks it incomplete and surfaces a structured error.
|
||||
fn read_children(&mut self, node: &S::Node, raw_depth: u8) -> ChildRead<S::Node> {
|
||||
let mut elements = Vec::new();
|
||||
let mut complete = true;
|
||||
match self.source.first_child(node) {
|
||||
Ok(first) => {
|
||||
let mut current = first;
|
||||
loop {
|
||||
if elements.len() >= self.budget.max_siblings {
|
||||
self.stats.traversal.limits.child_hits += 1;
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
if ensure_budget(self.budget.deadline).is_err() {
|
||||
self.note_deadline_exhausted();
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
let next = self.source.next_sibling(¤t);
|
||||
elements.push(current);
|
||||
match next {
|
||||
Ok(sibling) => current = sibling,
|
||||
Err(failure) if failure.is_exhaustion() => break,
|
||||
Err(failure) => {
|
||||
self.record(
|
||||
failure,
|
||||
EnumerationAxis::NextSibling,
|
||||
raw_depth,
|
||||
elements.len(),
|
||||
);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(failure) if failure.is_exhaustion() => {}
|
||||
Err(failure) => {
|
||||
self.record(failure, EnumerationAxis::FirstChild, raw_depth, 0);
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
ChildRead { elements, complete }
|
||||
}
|
||||
|
||||
fn visit_children(
|
||||
&mut self,
|
||||
read: ChildRead<S::Node>,
|
||||
depths: (u8, u8),
|
||||
) -> (Vec<ObservedSubtree>, bool) {
|
||||
let mut complete = read.complete;
|
||||
let mut children = Vec::new();
|
||||
let mut predecessors_complete = true;
|
||||
for (child_index, child) in read.elements.into_iter().enumerate() {
|
||||
match self.visit(child, depths.0, depths.1.saturating_add(1)) {
|
||||
Some(subtree) => {
|
||||
complete &= subtree.is_complete();
|
||||
let edge_complete = retained_edge_certainty(&mut predecessors_complete, true);
|
||||
children.push(
|
||||
subtree
|
||||
.with_source_child_index(child_index)
|
||||
.with_predecessors_complete(edge_complete),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
retained_edge_certainty(&mut predecessors_complete, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
(children, complete)
|
||||
}
|
||||
|
||||
fn is_ancestor(&self, key: &NodeKey, node: &S::Node) -> bool {
|
||||
match key {
|
||||
NodeKey::Runtime(_) => self
|
||||
.ancestors
|
||||
.iter()
|
||||
.any(|(ancestor_key, _)| ancestor_key == key),
|
||||
NodeKey::Unavailable => self
|
||||
.ancestors
|
||||
.iter()
|
||||
.any(|(_, ancestor)| self.source.same_element(ancestor, node)),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(
|
||||
&mut self,
|
||||
failure: UiaFailure,
|
||||
axis: EnumerationAxis,
|
||||
raw_depth: u8,
|
||||
child_index: usize,
|
||||
) {
|
||||
self.stats.reads.health.cannot_complete += 1;
|
||||
if self.failures.len() >= MAX_REPORTED_FAILURES {
|
||||
self.suppressed_failures += 1;
|
||||
return;
|
||||
}
|
||||
self.failures
|
||||
.push(enumeration_error(failure, axis, raw_depth, child_index));
|
||||
}
|
||||
|
||||
fn note_visit(&mut self, logical_depth: u8, raw_depth: u8) {
|
||||
self.stats.traversal.nodes_visited += 1;
|
||||
self.stats.traversal.max_logical_depth =
|
||||
self.stats.traversal.max_logical_depth.max(logical_depth);
|
||||
self.stats.traversal.max_raw_depth = self.stats.traversal.max_raw_depth.max(raw_depth);
|
||||
}
|
||||
|
||||
fn note_deadline_exhausted(&mut self) {
|
||||
self.stats.reads.health.deadline_exhausted += 1;
|
||||
self.complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the structured error a failed enumeration surfaces.
|
||||
///
|
||||
/// Shape only: the failure's own HRESULT or sentinel, which axis faulted, how
|
||||
/// deep the walk was, and how many siblings had been retired. No name, class
|
||||
/// name, property value, window title, or provider description may enter here —
|
||||
/// adapter error text is cloned into session trace segments and the trace HTML
|
||||
/// export, so anything baked in is persisted.
|
||||
fn enumeration_error(
|
||||
failure: UiaFailure,
|
||||
axis: EnumerationAxis,
|
||||
raw_depth: u8,
|
||||
child_index: usize,
|
||||
) -> AdapterError {
|
||||
uia_failure_error(failure, "enumerate the children of an element").with_details(json!({
|
||||
"kind": "child_enumeration_failed",
|
||||
"axis": axis.as_str(),
|
||||
"raw_depth": raw_depth,
|
||||
"child_index": child_index,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Names how many further faults the cap dropped.
|
||||
///
|
||||
/// The cap keeps one pathological target from flooding a trace segment, but a
|
||||
/// consumer that sees eight errors and no count would read a systemic failure
|
||||
/// as a local one. The number travels; the dropped errors do not.
|
||||
fn annotate_suppressed(error: &mut AdapterError, suppressed: u32) {
|
||||
if suppressed == 0 {
|
||||
return;
|
||||
}
|
||||
let mut details = error.details.take().unwrap_or_else(|| json!({}));
|
||||
if let Some(map) = details.as_object_mut() {
|
||||
map.insert("suppressed_failures".into(), json!(suppressed));
|
||||
}
|
||||
error.details = Some(details);
|
||||
}
|
||||
|
||||
/// Records whether every native predecessor of this edge was retained.
|
||||
fn retained_edge_certainty(prefix_certain: &mut bool, retained: bool) -> bool {
|
||||
let edge_certain = *prefix_certain;
|
||||
*prefix_certain &= retained;
|
||||
edge_certain
|
||||
}
|
||||
166
crates/windows/src/tree/walker_fake.rs
Normal file
166
crates/windows/src/tree/walker_fake.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
use agent_desktop_core::{
|
||||
AdapterError, Deadline, LocatorEvidence, LocatorField, ObservationRoot, ProcessId, WindowInfo,
|
||||
WindowState,
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::tree::automation::{ERR_NONE, UiaFailure};
|
||||
use crate::tree::properties::ElementProperties;
|
||||
use crate::tree::walker::{NodeKey, TreeSource, WalkBudget, WalkOutcome, walk_from_root};
|
||||
|
||||
/// The benign end-of-list pair A14-3 measured: `code() == 0` with `result()`
|
||||
/// `None`, on both build 17763 and build 26100.
|
||||
pub(crate) const EXHAUSTION: UiaFailure = UiaFailure::Sentinel(ERR_NONE);
|
||||
|
||||
/// The real enumeration fault A14-4 measured: descent against a provider whose
|
||||
/// process has gone away returns `E_FAIL` with `result()` `Some`.
|
||||
pub(crate) const E_FAIL: i32 = 0x8000_4005_u32 as i32;
|
||||
pub(crate) const REAL_FAILURE: UiaFailure = UiaFailure::Hresult(E_FAIL);
|
||||
|
||||
/// An in-memory enumerator that answers on the same trait the live UI
|
||||
/// Automation walker implements, so the correctness branches these tests drive
|
||||
/// are the branches that ship.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FakeTree {
|
||||
first: HashMap<i32, i32>,
|
||||
next: HashMap<i32, i32>,
|
||||
alias: HashMap<i32, i32>,
|
||||
unkeyed: HashSet<i32>,
|
||||
wrappers: HashSet<i32>,
|
||||
first_child_faults: HashSet<i32>,
|
||||
next_sibling_faults: HashSet<i32>,
|
||||
}
|
||||
|
||||
impl FakeTree {
|
||||
pub(crate) fn with_children(mut self, parent: i32, children: &[i32]) -> Self {
|
||||
if let Some(first) = children.first() {
|
||||
self.first.insert(parent, *first);
|
||||
}
|
||||
for pair in children.windows(2) {
|
||||
self.next.insert(pair[0], pair[1]);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_chain(self, chain: &[i32]) -> Self {
|
||||
chain
|
||||
.windows(2)
|
||||
.fold(self, |tree, pair| tree.with_children(pair[0], &pair[1..]))
|
||||
}
|
||||
|
||||
/// Makes one node report the identity of another, which is what a cycle
|
||||
/// looks like from the client side.
|
||||
pub(crate) fn aliasing(mut self, node: i32, to: i32) -> Self {
|
||||
self.alias.insert(node, to);
|
||||
self
|
||||
}
|
||||
|
||||
/// Makes a node publish no runtime id, forcing the guard onto its
|
||||
/// element-comparison fallback.
|
||||
pub(crate) fn unkeyed(mut self, node: i32) -> Self {
|
||||
self.unkeyed.insert(node);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn wrapping(mut self, node: i32) -> Self {
|
||||
self.wrappers.insert(node);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn faulting_on_first_child(mut self, node: i32) -> Self {
|
||||
self.first_child_faults.insert(node);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn faulting_on_next_sibling(mut self, node: i32) -> Self {
|
||||
self.next_sibling_faults.insert(node);
|
||||
self
|
||||
}
|
||||
|
||||
/// A sibling list that never ends. The ancestor guard cannot see it: no
|
||||
/// element repeats on any root-to-node path.
|
||||
pub(crate) fn looping_siblings(mut self, node: i32) -> Self {
|
||||
self.next.insert(node, node);
|
||||
self
|
||||
}
|
||||
|
||||
fn alias_of(&self, node: i32) -> i32 {
|
||||
self.alias.get(&node).copied().unwrap_or(node)
|
||||
}
|
||||
}
|
||||
|
||||
impl TreeSource for FakeTree {
|
||||
type Node = i32;
|
||||
|
||||
fn first_child(&self, node: &i32) -> Result<i32, UiaFailure> {
|
||||
if self.first_child_faults.contains(node) {
|
||||
return Err(REAL_FAILURE);
|
||||
}
|
||||
self.first.get(node).copied().ok_or(EXHAUSTION)
|
||||
}
|
||||
|
||||
fn next_sibling(&self, node: &i32) -> Result<i32, UiaFailure> {
|
||||
if self.next_sibling_faults.contains(node) {
|
||||
return Err(REAL_FAILURE);
|
||||
}
|
||||
self.next.get(node).copied().ok_or(EXHAUSTION)
|
||||
}
|
||||
|
||||
fn identity(&self, node: &i32) -> NodeKey {
|
||||
if self.unkeyed.contains(node) {
|
||||
return NodeKey::Unavailable;
|
||||
}
|
||||
NodeKey::Runtime(vec![self.alias_of(*node)])
|
||||
}
|
||||
|
||||
fn same_element(&self, left: &i32, right: &i32) -> bool {
|
||||
self.alias_of(*left) == self.alias_of(*right)
|
||||
}
|
||||
|
||||
fn evidence(&self, _node: &i32) -> (LocatorEvidence, u64) {
|
||||
(
|
||||
ElementProperties::default()
|
||||
.into_locator_evidence(LocatorField::Unknown, LocatorField::Unknown),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_web_wrapper(&self, node: &i32) -> bool {
|
||||
self.wrappers.contains(node)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn deadline() -> Deadline {
|
||||
Deadline::standard().expect("a standard deadline")
|
||||
}
|
||||
|
||||
pub(crate) fn budget(max_logical_depth: u8) -> WalkBudget {
|
||||
WalkBudget::new(max_logical_depth, deadline())
|
||||
}
|
||||
|
||||
pub(crate) fn window() -> WindowInfo {
|
||||
WindowInfo {
|
||||
id: String::from("w-fake"),
|
||||
title: String::new(),
|
||||
app: String::new(),
|
||||
pid: ProcessId::new(0),
|
||||
process_instance: None,
|
||||
bounds: None,
|
||||
state: WindowState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn walk(fake: &FakeTree, budget: WalkBudget) -> WalkOutcome {
|
||||
let window = window();
|
||||
walk_from_root(fake, &1, &ObservationRoot::Window(&window), budget)
|
||||
.expect("the walk assembles an observation")
|
||||
}
|
||||
|
||||
/// Runs a walk that is expected not to assemble an observation at all, so the
|
||||
/// error the caller receives can be asserted.
|
||||
pub(crate) fn walk_expecting_failure(fake: &FakeTree, budget: WalkBudget) -> AdapterError {
|
||||
let window = window();
|
||||
walk_from_root(fake, &1, &ObservationRoot::Window(&window), budget)
|
||||
.err()
|
||||
.expect("the walk was expected to fail")
|
||||
}
|
||||
193
crates/windows/src/tree/walker_source.rs
Normal file
193
crates/windows/src/tree/walker_source.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
use agent_desktop_core::{AdapterError, ObservationRoot};
|
||||
|
||||
use super::element::UIAElement;
|
||||
use super::walker::{WalkBudget, WalkOutcome, walk_from_root};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
use crate::tree::automation::{UiaFailure, automation_client, failure_of, uia_error};
|
||||
use crate::tree::cache::{build_walk_cache_request, policy_for_root};
|
||||
use crate::tree::element::UIAElement;
|
||||
use crate::tree::properties::{read_cached, read_live};
|
||||
use crate::tree::walker::{
|
||||
NodeKey, TreeSource, is_web_wrapper, walk_available_actions, walk_role,
|
||||
};
|
||||
use agent_desktop_core::{AdapterError, LocatorEvidence};
|
||||
use uiautomation::UIAutomation;
|
||||
use uiautomation::core::{UICacheRequest, UITreeWalker};
|
||||
|
||||
/// The production enumeration surface: the raw view walker, plus the cache
|
||||
/// request the provider class earned.
|
||||
pub struct UiaTreeSource {
|
||||
client: UIAutomation,
|
||||
walker: UITreeWalker,
|
||||
cache: Option<UICacheRequest>,
|
||||
}
|
||||
|
||||
impl UiaTreeSource {
|
||||
pub fn for_root(root: &UIAElement) -> Result<Self, AdapterError> {
|
||||
let client = automation_client()?;
|
||||
let walker = client
|
||||
.get_raw_view_walker()
|
||||
.map_err(|error| uia_error(&error, "open the raw view tree walker"))?;
|
||||
let cache = if policy_for_root(root).batches() {
|
||||
Some(build_walk_cache_request(&client)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self {
|
||||
client,
|
||||
walker,
|
||||
cache,
|
||||
})
|
||||
}
|
||||
|
||||
/// Prefetches the walk property set onto the caller's root.
|
||||
///
|
||||
/// The root arrives from `ElementFromHandle` or from a stored ref, so
|
||||
/// it carries no cache of its own; without this the root alone would
|
||||
/// read `Unknown` for every property while its children read fine.
|
||||
pub fn prepare_root(&self, root: &UIAElement) -> Result<UIAElement, AdapterError> {
|
||||
match &self.cache {
|
||||
Some(request) => root
|
||||
.0
|
||||
.build_updated_cache(request)
|
||||
.map(UIAElement::from)
|
||||
.map_err(|error| {
|
||||
uia_error(&error, "prefetch the properties of a root element")
|
||||
}),
|
||||
None => Ok(root.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TreeSource for UiaTreeSource {
|
||||
type Node = UIAElement;
|
||||
|
||||
fn first_child(&self, node: &UIAElement) -> Result<UIAElement, UiaFailure> {
|
||||
match &self.cache {
|
||||
Some(request) => self.walker.get_first_child_build_cache(&node.0, request),
|
||||
None => self.walker.get_first_child(&node.0),
|
||||
}
|
||||
.map(UIAElement::from)
|
||||
.map_err(|error| failure_of(&error))
|
||||
}
|
||||
|
||||
fn next_sibling(&self, node: &UIAElement) -> Result<UIAElement, UiaFailure> {
|
||||
match &self.cache {
|
||||
Some(request) => self.walker.get_next_sibling_build_cache(&node.0, request),
|
||||
None => self.walker.get_next_sibling(&node.0),
|
||||
}
|
||||
.map(UIAElement::from)
|
||||
.map_err(|error| failure_of(&error))
|
||||
}
|
||||
|
||||
fn identity(&self, node: &UIAElement) -> NodeKey {
|
||||
match node.0.get_runtime_id() {
|
||||
Ok(runtime_id) if !runtime_id.is_empty() => NodeKey::Runtime(runtime_id),
|
||||
_ => NodeKey::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn same_element(&self, left: &UIAElement, right: &UIAElement) -> bool {
|
||||
self.client
|
||||
.compare_elements(&left.0, &right.0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn evidence(&self, node: &UIAElement) -> (LocatorEvidence, u64) {
|
||||
let (properties, errors) = match &self.cache {
|
||||
Some(_) => read_cached(node),
|
||||
None => read_live(node),
|
||||
};
|
||||
let failed = u64::try_from(errors.len()).unwrap_or(u64::MAX);
|
||||
(
|
||||
properties.into_locator_evidence(walk_role(node), walk_available_actions(node)),
|
||||
failed,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_web_wrapper(&self, node: &UIAElement) -> bool {
|
||||
is_web_wrapper(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod imp {
|
||||
use crate::tree::automation::{ERR_NONE, UiaFailure};
|
||||
use crate::tree::element::UIAElement;
|
||||
use crate::tree::properties::ElementProperties;
|
||||
use crate::tree::walker::{
|
||||
NodeKey, TreeSource, is_web_wrapper, walk_available_actions, walk_role,
|
||||
};
|
||||
use agent_desktop_core::{AdapterError, LocatorEvidence};
|
||||
|
||||
/// Canned arm so the walk's entry point, and every module that calls it,
|
||||
/// compile and run on a non-Windows lane. It enumerates nothing, which is
|
||||
/// the exhaustion answer, so a walk here yields a single complete node.
|
||||
pub struct UiaTreeSource;
|
||||
|
||||
impl UiaTreeSource {
|
||||
pub fn for_root(_root: &UIAElement) -> Result<Self, AdapterError> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn prepare_root(&self, root: &UIAElement) -> Result<UIAElement, AdapterError> {
|
||||
Ok(root.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl TreeSource for UiaTreeSource {
|
||||
type Node = UIAElement;
|
||||
|
||||
fn first_child(&self, _node: &UIAElement) -> Result<UIAElement, UiaFailure> {
|
||||
Err(UiaFailure::Sentinel(ERR_NONE))
|
||||
}
|
||||
|
||||
fn next_sibling(&self, _node: &UIAElement) -> Result<UIAElement, UiaFailure> {
|
||||
Err(UiaFailure::Sentinel(ERR_NONE))
|
||||
}
|
||||
|
||||
fn identity(&self, _node: &UIAElement) -> NodeKey {
|
||||
NodeKey::Unavailable
|
||||
}
|
||||
|
||||
fn same_element(&self, _left: &UIAElement, _right: &UIAElement) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn evidence(&self, node: &UIAElement) -> (LocatorEvidence, u64) {
|
||||
(
|
||||
ElementProperties::default()
|
||||
.into_locator_evidence(walk_role(node), walk_available_actions(node)),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_web_wrapper(&self, node: &UIAElement) -> bool {
|
||||
is_web_wrapper(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::UiaTreeSource;
|
||||
|
||||
/// Walks a live UI Automation subtree from an arbitrary root element.
|
||||
///
|
||||
/// The cache policy is chosen once, from the root's provider class, and the
|
||||
/// root is prefetched to match it, so the whole walk reads through one
|
||||
/// consistent mechanism.
|
||||
pub fn walk_uia_subtree(
|
||||
root: &UIAElement,
|
||||
root_source: &ObservationRoot<'_>,
|
||||
budget: WalkBudget,
|
||||
) -> Result<WalkOutcome, AdapterError> {
|
||||
let source = UiaTreeSource::for_root(root)?;
|
||||
let prepared = source.prepare_root(root)?;
|
||||
walk_from_root(&source, &prepared, root_source, budget)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "walker_source_tests.rs"]
|
||||
mod tests;
|
||||
118
crates/windows/src/tree/walker_source_tests.rs
Normal file
118
crates/windows/src/tree/walker_source_tests.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use super::*;
|
||||
use crate::tree::walker_fake::{budget, window};
|
||||
use agent_desktop_core::ObservationRoot;
|
||||
|
||||
/// The crate's own `get_children` retires end-of-siblings and a cross-process
|
||||
/// fault through the same arm; `SetFocus` moves the desktop foreground (A3-4)
|
||||
/// and is therefore not headless; ref allocation belongs to core alone.
|
||||
#[test]
|
||||
fn the_walk_issues_no_banned_call() {
|
||||
let sources = [
|
||||
include_str!("walker.rs"),
|
||||
include_str!("walker_enumerate.rs"),
|
||||
include_str!("walker_source.rs"),
|
||||
include_str!("walker_tests.rs"),
|
||||
include_str!("walker_fake.rs"),
|
||||
include_str!("walker_source_tests.rs"),
|
||||
];
|
||||
let banned = [
|
||||
concat!("get_", "children"),
|
||||
concat!("Set", "Focus"),
|
||||
concat!("allocate_", "refs"),
|
||||
];
|
||||
for source in sources {
|
||||
for line in source.lines() {
|
||||
let is_prose =
|
||||
line.trim_start().starts_with("///") || line.trim_start().starts_with("//!");
|
||||
for call in banned {
|
||||
assert!(
|
||||
is_prose || !line.contains(call),
|
||||
"the walk must never call {call}: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_only {
|
||||
use super::*;
|
||||
use crate::tree::automation::root_from_hwnd;
|
||||
use crate::tree::fixture::HostedFixture;
|
||||
use crate::tree::walker_fake::deadline;
|
||||
|
||||
/// The live half of the discriminator check. A walk whose classification is
|
||||
/// inverted reports this cross-process window incomplete, so an inverted
|
||||
/// discriminator fails here rather than passing silently.
|
||||
#[test]
|
||||
fn a_cross_process_fixture_walk_terminates_finds_controls_and_reports_complete() {
|
||||
crate::tree::fixture::ensure_test_apartment();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let root =
|
||||
root_from_hwnd(fixture.handle(), deadline()).expect("the fixture window resolves");
|
||||
let window = window();
|
||||
|
||||
let outcome = walk_uia_subtree(&root, &ObservationRoot::Window(&window), budget(50))
|
||||
.expect("the walk assembles an observation");
|
||||
|
||||
assert!(
|
||||
outcome.failures.is_empty(),
|
||||
"a live cross-process walk raised an enumeration failure"
|
||||
);
|
||||
assert!(
|
||||
outcome.tree.is_complete(),
|
||||
"a live cross-process walk reported an incomplete tree"
|
||||
);
|
||||
let projected = outcome
|
||||
.tree
|
||||
.into_accessibility_tree()
|
||||
.expect("core accepts a complete observation");
|
||||
assert!(
|
||||
!projected.children.is_empty(),
|
||||
"the walk found none of the fixture's created controls"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every node the live walk emits carries the seams 2.3 fills, so 2.3 can
|
||||
/// fill them without reshaping the traversal.
|
||||
#[test]
|
||||
fn the_live_walk_calls_the_vocabulary_seams_for_every_node() {
|
||||
crate::tree::fixture::ensure_test_apartment();
|
||||
let fixture = HostedFixture::spawn().expect("the fixture host starts");
|
||||
let root =
|
||||
root_from_hwnd(fixture.handle(), deadline()).expect("the fixture window resolves");
|
||||
let window = window();
|
||||
|
||||
let outcome = walk_uia_subtree(&root, &ObservationRoot::Window(&window), budget(50))
|
||||
.expect("the walk assembles an observation");
|
||||
let projected = outcome
|
||||
.tree
|
||||
.into_accessibility_tree()
|
||||
.expect("core accepts a complete observation");
|
||||
|
||||
assert_eq!(projected.role, "unknown");
|
||||
assert!(projected.presentation.available_actions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod non_windows_only {
|
||||
use super::*;
|
||||
use crate::tree::element::{CannedElement, UIAElement};
|
||||
|
||||
/// The canned arm exists so every module that calls the walk compiles and
|
||||
/// runs off Windows. It enumerates nothing, which is the exhaustion answer,
|
||||
/// so its walk is a single complete node.
|
||||
#[test]
|
||||
fn the_canned_source_walks_a_single_complete_node() {
|
||||
let window = window();
|
||||
let root = UIAElement::from(CannedElement);
|
||||
|
||||
let outcome = walk_uia_subtree(&root, &ObservationRoot::Window(&window), budget(10))
|
||||
.expect("the canned walk assembles an observation");
|
||||
|
||||
assert!(outcome.tree.is_complete());
|
||||
assert!(outcome.failures.is_empty());
|
||||
assert!(outcome.tree.into_accessibility_tree().is_ok());
|
||||
}
|
||||
}
|
||||
349
crates/windows/src/tree/walker_tests.rs
Normal file
349
crates/windows/src/tree/walker_tests.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
use super::*;
|
||||
use crate::tree::automation::UiaFailure;
|
||||
use crate::tree::walker_enumerate::TreeWalk;
|
||||
use crate::tree::walker_fake::{
|
||||
E_FAIL, EXHAUSTION, FakeTree, REAL_FAILURE, budget, walk, walk_expecting_failure,
|
||||
};
|
||||
use agent_desktop_core::{Deadline, ErrorCode};
|
||||
|
||||
/// The fakes encode the measured pair, not a reading of the crate. If this
|
||||
/// fails, every other fake-driven assertion below is testing the wrong model.
|
||||
#[test]
|
||||
fn the_fakes_are_built_from_the_measured_error_pair() {
|
||||
assert!(EXHAUSTION.is_exhaustion());
|
||||
assert!(!REAL_FAILURE.is_exhaustion());
|
||||
assert_eq!(REAL_FAILURE, UiaFailure::Hresult(E_FAIL));
|
||||
}
|
||||
|
||||
/// A genuinely cyclic child graph: the deepest node's child is the root. Only
|
||||
/// the guard ends this walk within the requested depth.
|
||||
#[test]
|
||||
fn a_repeated_identity_is_skipped_once_and_the_walk_terminates() {
|
||||
let fake = FakeTree::default()
|
||||
.with_chain(&[1, 2, 3])
|
||||
.with_children(3, &[1]);
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
|
||||
assert_eq!(outcome.stats.traversal.cycles_skipped, 1);
|
||||
assert_eq!(outcome.stats.traversal.limits.depth_hits, 0);
|
||||
assert!(outcome.tree.is_complete());
|
||||
}
|
||||
|
||||
/// The key is the identity the provider publishes, not the element the client
|
||||
/// happens to hold: UI Automation returns a new proxy per query, so a distinct
|
||||
/// proxy reporting an ancestor's runtime id is the same element.
|
||||
#[test]
|
||||
fn a_cycle_is_detected_by_published_identity_and_not_by_node_value() {
|
||||
let fake = FakeTree::default().with_chain(&[1, 2, 3, 4]).aliasing(4, 2);
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
|
||||
assert_eq!(outcome.stats.traversal.cycles_skipped, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_provider_without_a_runtime_id_is_guarded_by_element_comparison() {
|
||||
let fake = FakeTree::default()
|
||||
.with_chain(&[1, 2, 3, 4])
|
||||
.aliasing(4, 2)
|
||||
.unkeyed(2)
|
||||
.unkeyed(4);
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
|
||||
assert_eq!(outcome.stats.traversal.cycles_skipped, 1);
|
||||
}
|
||||
|
||||
/// A cycle on one branch must not prune an unrelated branch that reaches the
|
||||
/// same element, which is what a global visited set would do.
|
||||
#[test]
|
||||
fn the_guard_is_an_ancestor_path_and_not_a_global_visited_set() {
|
||||
let fake = FakeTree::default()
|
||||
.with_children(1, &[2, 3])
|
||||
.with_children(2, &[4])
|
||||
.with_children(3, &[5])
|
||||
.aliasing(5, 4);
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
|
||||
assert_eq!(outcome.stats.traversal.cycles_skipped, 0);
|
||||
assert!(outcome.tree.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benign_exhaustion_produces_a_complete_subtree() {
|
||||
let fake = FakeTree::default()
|
||||
.with_children(1, &[2, 3])
|
||||
.with_children(2, &[4]);
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
|
||||
assert!(outcome.tree.is_complete());
|
||||
assert!(outcome.failures.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_descent_failure_produces_an_incomplete_tree_and_a_structured_error() {
|
||||
let fake = FakeTree::default()
|
||||
.with_children(1, &[2, 3])
|
||||
.with_children(2, &[4])
|
||||
.faulting_on_first_child(4);
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
|
||||
assert!(!outcome.tree.is_complete());
|
||||
assert!(!outcome.failures.is_empty());
|
||||
assert!(outcome.tree.into_accessibility_tree().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_sibling_failure_retires_the_prefix_and_still_reports_incomplete() {
|
||||
let fake = FakeTree::default()
|
||||
.with_children(1, &[2, 3, 4])
|
||||
.faulting_on_next_sibling(3);
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
|
||||
assert!(!outcome.tree.is_complete());
|
||||
assert!(!outcome.failures.is_empty());
|
||||
}
|
||||
|
||||
/// The enumeration error carries shape and nothing else: no value, name, class
|
||||
/// name, window title, or provider description may reach a message that
|
||||
/// `ref_action.rs` clones into session trace segments.
|
||||
#[test]
|
||||
fn an_enumeration_error_carries_shape_only() {
|
||||
let fake = FakeTree::default()
|
||||
.with_children(1, &[2])
|
||||
.faulting_on_first_child(2);
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
let failure = outcome.failures.first().expect("a structured failure");
|
||||
let details = failure.details.as_ref().expect("structured details");
|
||||
|
||||
assert_eq!(details["kind"], "child_enumeration_failed");
|
||||
let keys: Vec<&String> = details
|
||||
.as_object()
|
||||
.expect("a details object")
|
||||
.keys()
|
||||
.collect();
|
||||
assert_eq!(keys, ["axis", "child_index", "kind", "raw_depth"]);
|
||||
assert!(failure.message.contains("UI Automation"));
|
||||
assert!(
|
||||
failure
|
||||
.platform_detail
|
||||
.as_ref()
|
||||
.expect("a platform detail")
|
||||
.contains("0x80004005")
|
||||
);
|
||||
}
|
||||
|
||||
/// The child count this boundary also emits is asserted on the logical
|
||||
/// boundary below instead: core exposes `children_count` only through
|
||||
/// `into_accessibility_tree()`, which by contract refuses the incomplete tree
|
||||
/// raw-depth truncation must produce.
|
||||
#[test]
|
||||
fn raw_depth_exhaustion_marks_incomplete_and_core_refuses_the_projection() {
|
||||
let fake = FakeTree::default().with_chain(&[1, 2, 3, 4]);
|
||||
|
||||
let outcome = walk(&fake, budget(50).with_max_raw_depth(1));
|
||||
|
||||
assert!(!outcome.tree.is_complete());
|
||||
assert!(outcome.stats.traversal.limits.depth_hits > 0);
|
||||
assert!(outcome.tree.into_accessibility_tree().is_err());
|
||||
}
|
||||
|
||||
/// A depth boundary emits a child count in place of the children it did not
|
||||
/// descend into, which is what makes the boundary a drill-down target rather
|
||||
/// than a silent leaf.
|
||||
#[test]
|
||||
fn a_depth_boundary_reports_a_child_count_instead_of_children() {
|
||||
let fake = FakeTree::default().with_chain(&[1, 2, 3]);
|
||||
|
||||
let outcome = walk(&fake, budget(1));
|
||||
let root = outcome
|
||||
.tree
|
||||
.into_accessibility_tree()
|
||||
.expect("core accepts a complete observation");
|
||||
let boundary = root.children.first().expect("the root retained its child");
|
||||
|
||||
assert!(boundary.children.is_empty());
|
||||
assert!(boundary.children_count.is_some());
|
||||
}
|
||||
|
||||
/// Without the `is_web_wrapper` seam the two counters can never disagree, so
|
||||
/// this assertion would be testing behaviour 2.2 ships no mechanism to
|
||||
/// produce. The control below is what makes that claim checkable.
|
||||
#[test]
|
||||
fn logical_and_raw_depth_diverge_when_a_node_is_a_web_wrapper() {
|
||||
let wrapped = FakeTree::default().with_chain(&[1, 2, 3]).wrapping(2);
|
||||
|
||||
let outcome = walk(&wrapped, budget(10));
|
||||
|
||||
assert!(outcome.stats.traversal.max_logical_depth < outcome.stats.traversal.max_raw_depth);
|
||||
assert_eq!(outcome.stats.traversal.web_wrapper_nodes, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_and_raw_depth_agree_when_no_node_is_a_web_wrapper() {
|
||||
let plain = FakeTree::default().with_chain(&[1, 2, 3]);
|
||||
|
||||
let outcome = walk(&plain, budget(10));
|
||||
|
||||
assert_eq!(
|
||||
outcome.stats.traversal.max_logical_depth,
|
||||
outcome.stats.traversal.max_raw_depth
|
||||
);
|
||||
assert_eq!(outcome.stats.traversal.web_wrapper_nodes, 0);
|
||||
}
|
||||
|
||||
/// The removal this asserts is the one a walk most easily ships without: the
|
||||
/// exit taken when enumeration faults part-way down a subtree.
|
||||
#[test]
|
||||
fn the_cycle_guard_is_unwound_on_the_error_exit_path() {
|
||||
let fake = FakeTree::default()
|
||||
.with_chain(&[1, 2, 3])
|
||||
.faulting_on_first_child(3);
|
||||
let mut walk = TreeWalk::new(&fake, budget(10));
|
||||
|
||||
let subtree = walk.visit(1, 0, 0);
|
||||
|
||||
assert!(subtree.is_some());
|
||||
assert_eq!(walk.ancestor_depth(), 0);
|
||||
let (_, complete, failures) = walk.finish().expect("the guard left nothing behind");
|
||||
assert!(!complete, "a faulting walk must not report complete");
|
||||
assert!(!failures.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cycle_guard_is_unwound_when_the_deadline_expires() {
|
||||
let fake = FakeTree::default().with_chain(&[1, 2, 3]);
|
||||
let expired = WalkBudget::new(10, Deadline::after(1).expect("a one millisecond deadline"));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
let mut walk = TreeWalk::new(&fake, expired);
|
||||
|
||||
let subtree = walk.visit(1, 0, 0);
|
||||
|
||||
assert!(subtree.is_none());
|
||||
assert_eq!(walk.ancestor_depth(), 0);
|
||||
let (stats, complete, _) = walk.finish().expect("the guard left nothing behind");
|
||||
assert!(
|
||||
!complete,
|
||||
"a walk cut short by its deadline must report incomplete, not merely unwind"
|
||||
);
|
||||
assert!(stats.reads.health.deadline_exhausted > 0);
|
||||
}
|
||||
|
||||
/// A sibling list that never terminates is invisible to the ancestor guard, so
|
||||
/// the sibling bound is what makes the enumeration loop total.
|
||||
#[test]
|
||||
fn an_endless_sibling_list_terminates_and_reports_incomplete() {
|
||||
let fake = FakeTree::default()
|
||||
.with_children(1, &[2])
|
||||
.looping_siblings(2);
|
||||
|
||||
let outcome = walk(&fake, budget(10).with_max_siblings(4));
|
||||
|
||||
assert!(!outcome.tree.is_complete());
|
||||
assert!(outcome.stats.traversal.limits.child_hits > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_complete_case_projects_and_the_incomplete_case_is_refused() {
|
||||
let complete = FakeTree::default().with_children(1, &[2, 3]);
|
||||
let incomplete = FakeTree::default()
|
||||
.with_children(1, &[2, 3])
|
||||
.faulting_on_first_child(2);
|
||||
|
||||
assert!(
|
||||
walk(&complete, budget(10))
|
||||
.tree
|
||||
.into_accessibility_tree()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
walk(&incomplete, budget(10))
|
||||
.tree
|
||||
.into_accessibility_tree()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// A walk whose root never yielded a subtree has to say *why*. The two causes
|
||||
/// are not interchangeable: an expired budget is the caller's own limit and is
|
||||
/// retryable with a larger one, while a root that simply produced nothing is
|
||||
/// the target failing to answer.
|
||||
#[test]
|
||||
fn a_root_that_never_read_reports_the_cause_that_produced_it() {
|
||||
let fake = FakeTree::default().with_chain(&[1, 2]);
|
||||
let expired = WalkBudget::new(10, Deadline::after(1).expect("a one millisecond deadline"));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
|
||||
let error = walk_expecting_failure(&fake, expired);
|
||||
|
||||
assert_eq!(error.code, ErrorCode::Timeout);
|
||||
assert_eq!(
|
||||
error
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|details| details.get("kind"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("walk_root_unread")
|
||||
);
|
||||
}
|
||||
|
||||
/// `predecessors_complete` is what tells a consumer whether the child it is
|
||||
/// looking at is preceded by every sibling the provider actually had. A child
|
||||
/// after a dropped one must not claim an intact prefix.
|
||||
#[test]
|
||||
fn an_edge_after_a_dropped_sibling_reports_its_prefix_as_incomplete() {
|
||||
let fake = FakeTree::default()
|
||||
.with_children(1, &[2, 3, 4])
|
||||
.aliasing(2, 1);
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
let root = outcome
|
||||
.tree
|
||||
.into_accessibility_tree()
|
||||
.expect("core accepts the observation");
|
||||
|
||||
assert_eq!(
|
||||
root.children.len(),
|
||||
2,
|
||||
"the cycle child is dropped and its siblings retained"
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure cap keeps one pathological target from flooding a trace
|
||||
/// segment, but a consumer that sees the cap and no count would read a
|
||||
/// systemic failure as a local one. The count travels even though the dropped
|
||||
/// errors do not.
|
||||
#[test]
|
||||
fn faults_beyond_the_reporting_cap_are_counted_rather_than_vanishing() {
|
||||
let children: Vec<i32> = (2..=40).collect();
|
||||
let mut fake = FakeTree::default().with_children(1, &children);
|
||||
for child in &children {
|
||||
fake = fake.faulting_on_first_child(*child);
|
||||
}
|
||||
|
||||
let outcome = walk(&fake, budget(10));
|
||||
|
||||
assert!(!outcome.tree.is_complete());
|
||||
let suppressed = outcome
|
||||
.failures
|
||||
.last()
|
||||
.and_then(|error| error.details.as_ref())
|
||||
.and_then(|details| details.get("suppressed_failures"))
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
suppressed > 0,
|
||||
"a walk that dropped faults must say how many"
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.stats.reads.health.cannot_complete,
|
||||
suppressed + outcome.failures.len() as u64,
|
||||
"the counted total must account for every fault, reported or not"
|
||||
);
|
||||
}
|
||||
|
|
@ -992,15 +992,15 @@ Every sub-phase 2.0–2.15 below is held to the same definition of done, stated
|
|||
**Scope:**
|
||||
- `UIAElement` ownership wrapper — `AddRef`/`Release`, `Clone`/`Drop` safety mirroring the `AXElement` pattern (`pub(crate)` inner field to prevent double-free via raw pointer extraction)
|
||||
- `ElementFromHandle` roots for window entry
|
||||
- `TreeWalker` traversal with an ancestor-path cycle guard (mirrors macOS: reused pointers across sibling branches, not a global visited set)
|
||||
- `CacheRequest` batched attribute reads (the UIA analogue of `AXUIElementCopyMultipleAttributeValues`)
|
||||
- `TreeWalker` traversal with an ancestor-path cycle guard, never a global visited set. The macOS pointer-reuse rationale does not transfer and 2.0's probes measured nothing that would support it — neither 2.0 walker so much as calls `GetParent`: UI Automation hands back a *new* `IUIAutomationElement` proxy for every query, so pointer identity carries no information and the guard keys on runtime id (`GetRuntimeId`), with `CompareElements` as the fallback where a runtime id is unavailable. The ancestor-path requirement itself is unchanged, since a global visited set still prunes real subtrees
|
||||
- `CacheRequest` attribute reads (the UIA analogue of `AXUIElementCopyMultipleAttributeValues`), batched conditionally rather than always. A6-1 measured a 220-node Explorer window at 2.69x overall with the find phase 1.5x *slower* and the read phase ~298x faster, and A6-2 measured classic Notepad — 3 nodes, served by `UIAutomationClientsideProviders` inside the client process, so an uncached read costs no cross-process RPC and the request adds pure setup — as a net pessimization at 0.5763x overall and 0.436x on the find phase. The rule that ships is the one the Windows API Mapping table below already carries: cache only the properties that will be read, and skip caching when a root-level `ProviderDescription` read indicates an in-process client-side provider. There is deliberately no node-count arm — the node count is unknown at the moment the cache request is built
|
||||
- Committed probe examples: raw UIA dumps of Notepad and Explorer, checked in as evidence alongside the sub-phase plan
|
||||
|
||||
**Key APIs:** `IUIAutomation.ElementFromHandle()`, `IUIAutomationTreeWalker.GetFirstChild`/`GetNextSibling`, `CacheRequest` (`uiautomation` crate 0.25+ wrapping the `windows` crate's COM bindings; construct the client with `UIAutomation::new_direct()` — `UIAutomation::new()` initializes the COM library itself and would re-initialise the apartment 2.1 already established)
|
||||
**Key APIs:** `IUIAutomation.ElementFromHandle()`, `IUIAutomationTreeWalker.GetFirstChild`/`GetNextSibling`, `CacheRequest` (`uiautomation` crate 0.25+ wrapping the `windows` crate's COM bindings; construct the client by direct `CoCreateInstance` on **`CUIAutomation8`**, never through `UIAutomation::new()`. `new()` calls `CoInitializeEx(None, COINIT_MULTITHREADED)` itself and proceeds on any non-negative HRESULT, so on a thread already in the MTA it reads `S_FALSE` as success and permanently leaks one initialization count — the type has no `Drop` and the crate never calls `CoUninitialize` — a leak Phase 5's long-lived daemon accumulates, and on any STA host thread it returns `Err(RPC_E_CHANGED_MODE)` outright. `UIAutomation::new_direct()` avoids both, but it is `CoCreateInstance(&CUIAutomation, …)`, and that object returns `E_NOINTERFACE` for `IUIAutomation2` — so its calls carry no timeout and `ElementFromHandle` against a window that stopped dispatching blocks indefinitely (A14-11, A14-12: 59.09 s through a 30 s watchdog, against `UIA_E_TIMEOUT` in 2.05 s once `SetConnectionTimeout` is set). `CUIAutomation8` is the same direct `CoCreateInstance` with the same no-`CoInitializeEx` property, and it exposes the timeout; there is no fallback to the unbounded client)
|
||||
|
||||
**Depends on:** 2.1
|
||||
|
||||
**Exit criteria:** an internal tree-dump binary prints Notepad and Explorer trees with batched reads; `CacheRequest` attribute-batching correctness is unit-tested.
|
||||
**Exit criteria:** an internal tree-dump binary prints Notepad and Explorer trees, batching reads only where the provider class warrants it — A6-2 measured unconditional batching against an in-process client-side provider as a pessimization, so "with batched reads" is not a criterion the dump can be held to; `CacheRequest` attribute-batching correctness is unit-tested, asserting that a cached read equals its uncached counterpart rather than that it is faster, since A6-1 and A6-2 disagree on the multiplier by provider class.
|
||||
|
||||
**Est. PR size:** ~2k LOC
|
||||
|
||||
|
|
@ -1036,12 +1036,13 @@ Every sub-phase 2.0–2.15 below is held to the same definition of done, stated
|
|||
- **Resolver depth:** element re-identification searches to `ABSOLUTE_MAX_DEPTH` (50) — Electron elements commonly sit at depth 25+; implement in `crates/windows/src/tree/resolve.rs`
|
||||
- **Surface detection for Electron:** an Electron modal (file picker, dialog) may report as the focused window itself rather than a child; surface detection must check whether the focused window IS the target surface, checking both `ControlType` and `LocalizedControlType`/UIA patterns (analogous to macOS AXRole + AXSubrole); implement in `crates/windows/src/tree/surfaces.rs`
|
||||
- Progressive skeleton traversal (`--skeleton`, `--root`) needs no Windows-specific work beyond `get_subtree()` — core owns the flow
|
||||
- **What 2.2 hands over unresolved, and 2.4 must not assume away.** A target that dies part-way through a walk is **not detectable from the tree API**: A14-4 measured `get_next_sibling` returning the exact `code()`/`result()` pair a live provider returns at end-of-list, and A14-9 measured property reads answering locally with an empty string and no error, so a subtree whose provider died mid-list reports *complete* with fewer siblings than exist. Descent (`get_first_child`) does surface it as `E_FAIL`. A snapshot that must distinguish "this app has no more children" from "this app went away" needs an independent liveness check — re-resolving the root, or a process read — not the sibling terminator. The hang bound is the client's `ConnectionTimeout`, not the operation `Deadline`, which cannot interrupt a blocking `SendMessage` (A14-11)
|
||||
|
||||
**Key APIs:** `ControlViewWalker`, `FindAll`, `UICacheRequest`, `Chrome_WidgetWin_1` class match, `IVirtualDesktopManager` (read-only, for diagnostics)
|
||||
|
||||
**Depends on:** 2.3
|
||||
|
||||
**Exit criteria:** `snapshot --app Notepad -i` and Explorer return reffed trees on the runner; skeleton drill-down works; a VS Code snapshot at default depth finds 50+ refs through web-aware depth-skip alone (no force flag), and ≥100 refs with `--force-electron-a11y`; an Electron file-picker dialog is detected as a sheet surface.
|
||||
**Exit criteria:** every gate is rule-shaped rather than app-named, because nothing establishes which applications the runner image carries and a ref count or tree shape is an `app/provider` fact no CI assertion may rest on — A6-2 records the environment dependency that makes such numbers unportable, and 2.0's own scope rule already forbids generalizing them: `snapshot` against a resolvable window root returns a reffed tree with a non-empty descendant set; skeleton drill-down works; web-aware depth-skip demonstrably reduces the depth budget consumed on any wrapper-bearing target, and `--force-electron-a11y` returns no fewer refs than the same target without it; a modal dialog raised by a Chromium-based target is detected as a sheet surface. A gate whose target is absent from the runner image skips with the reason recorded, never a false green.
|
||||
|
||||
**Est. PR size:** ~2k LOC
|
||||
|
||||
|
|
@ -1102,7 +1103,7 @@ Every sub-phase 2.0–2.15 below is held to the same definition of done, stated
|
|||
|
||||
| Capability | Technology | Details |
|
||||
|------------|-----------|---------|
|
||||
| Tree root | `IUIAutomation.ElementFromHandle()` | Via `uiautomation` crate (v0.25+) wrapping UIA COM APIs via `windows` crate. Construct with `UIAutomation::new_direct()`, never `UIAutomation::new()` — the latter initializes the COM library itself and would re-initialise the apartment 2.1 established |
|
||||
| Tree root | `IUIAutomation.ElementFromHandle()` | Via `uiautomation` crate (v0.25+) wrapping UIA COM APIs via `windows` crate. Construct by direct `CoCreateInstance` on `CUIAutomation8`, never `UIAutomation::new()` — the latter initializes COM itself, takes `S_FALSE` as success on an MTA thread and leaks one initialization count per call, and returns `Err(RPC_E_CHANGED_MODE)` on an STA host thread. `UIAutomation::new_direct()` avoids that but builds `CUIAutomation`, which has no `IUIAutomation2` and therefore no call timeout (A14-12); `CUIAutomation8` sets `ConnectionTimeout`, which is what stops a non-dispatching target hanging the caller |
|
||||
| Children | `IUIAutomationTreeWalker.GetFirstChild` / `GetNextSibling` | With `CacheRequest` for batch attribute retrieval. The speedup is a phase split, not one multiplier: measured on UIA3 COM over a 220-node Explorer window reading 8 properties, building the cache makes the find pass *slower* (180 ms vs 117 ms) and the property-read pass ~300x faster (372 ms vs 1.2 ms), netting ~2.7x for a single full-tree read. Cache only the properties that will be read, and expect the win from repeated reads over a cached tree rather than from the walk. On plain Win32 trees served by in-process client-side providers, unconditional caching is a net pessimization |
|
||||
| Role mapping | `UIA ControlType` integers | Map to unified role enum in `tree/roles.rs` — e.g. `UIA_ButtonControlTypeId` → `button` |
|
||||
| Click | `InvokePattern.Invoke()` | Pattern-based; coordinate click via SendInput only under explicit physical policy |
|
||||
|
|
@ -1214,6 +1215,7 @@ Every sub-phase 2.0–2.15 below is held to the same definition of done, stated
|
|||
- Self-hosted interactive Windows runner registration — this is the first sub-phase whose gate needs a real desktop, so the runner is registered here rather than standing idle through the sub-phases that do not use it. A service-mode runner has no interactive desktop and cannot see UIA at all, so the runner launches from a Task Scheduler task triggered at log-on that runs `run.cmd` inside the interactive session
|
||||
- Public-repo hardening on that registration. This repository is public, and GitHub's own guidance is that self-hosted runners should almost never be used for public repositories, because any user can open a pull request and compromise the environment: the runner's workflow is `workflow_dispatch`-triggered only and never `pull_request`, the fork-PR approval policy is written down, and ephemeral/JIT versus persistent registration is an explicit recorded decision rather than a default
|
||||
- Registration is a measurement obligation as much as infrastructure: it creates the first non-console session this project can observe, and 2.12 closes 2.0's deferred RDP/session-isolation row by measuring it there rather than by documenting it (an interactive session is required for UIA to see a real desktop; `tscon` is the documented console-reattach workaround and leaves the machine unlocked — see Risk Register). Until that measurement lands, no Windows adapter behavior assumes console-session semantics and this document claims no RDP or remote-session support
|
||||
- `app/provider` — A14-1 measured the hosted runner rather than inferring it, finding `windows-latest` resolved to Server 2025 build 26100 on image `win25-vs2026` 20260714.173.1, with `qwinsta` reporting `>console` Active at session id 2, the probe process running in that session, `[Environment]::UserInteractive` true, window station `WinSta0` and desktop `Default` — one image on one date rather than a product contract, and contrary to Microsoft's own guidance for its hosted agents ([Configure for UI testing](https://learn.microsoft.com/en-us/azure/devops/pipelines/test/ui-testing-considerations)), which is why the case for this sub-phase's runner rests on presenting real applications on a representative shell and never on whether a session exists
|
||||
- `windows-e2e` workflow_dispatch job on that runner
|
||||
|
||||
**Key APIs:** `csc.exe`, WinForms `AutomationProperties.AutomationId`
|
||||
|
|
|
|||
799
docs/plans/2026-07-27-002-captures/explorer-com.json
Normal file
799
docs/plans/2026-07-27-002-captures/explorer-com.json
Normal file
|
|
@ -0,0 +1,799 @@
|
|||
{
|
||||
"capture": "uia-tree-census",
|
||||
"client_stack": "uia3-com",
|
||||
"control_types": [
|
||||
{
|
||||
"class_names": [
|
||||
"\"\"",
|
||||
"\"Button\""
|
||||
],
|
||||
"control_type": "50000",
|
||||
"nodes": 20,
|
||||
"with_automation_id": 20,
|
||||
"with_name": 20
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"SearchEditBox\"",
|
||||
"\"UIProperty\""
|
||||
],
|
||||
"control_type": "50004",
|
||||
"nodes": 9,
|
||||
"with_automation_id": 9,
|
||||
"with_name": 9
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"UIImage\""
|
||||
],
|
||||
"control_type": "50006",
|
||||
"nodes": 2,
|
||||
"with_automation_id": 2,
|
||||
"with_name": 2
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"UIItem\""
|
||||
],
|
||||
"control_type": "50007",
|
||||
"nodes": 2,
|
||||
"with_automation_id": 2,
|
||||
"with_name": 2
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"UIItemsView\""
|
||||
],
|
||||
"control_type": "50008",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50010",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50011",
|
||||
"nodes": 2,
|
||||
"with_automation_id": 2,
|
||||
"with_name": 2
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"msctls_progress32\""
|
||||
],
|
||||
"control_type": "50012",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"SelectorButton\""
|
||||
],
|
||||
"control_type": "50013",
|
||||
"nodes": 2,
|
||||
"with_automation_id": 2,
|
||||
"with_name": 2
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"StatusBarModuleInner\""
|
||||
],
|
||||
"control_type": "50017",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50018",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50019",
|
||||
"nodes": 3,
|
||||
"with_automation_id": 3,
|
||||
"with_name": 3
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"MetadataLabel\""
|
||||
],
|
||||
"control_type": "50020",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\"",
|
||||
"\"ToolbarWindow32\""
|
||||
],
|
||||
"control_type": "50021",
|
||||
"nodes": 5,
|
||||
"with_automation_id": 5,
|
||||
"with_name": 5
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"SysTreeView32\""
|
||||
],
|
||||
"control_type": "50023",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50024",
|
||||
"nodes": 10,
|
||||
"with_automation_id": 10,
|
||||
"with_name": 10
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50025",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"Element\"",
|
||||
"\"SelectorNoDefault\""
|
||||
],
|
||||
"control_type": "50026",
|
||||
"nodes": 2,
|
||||
"with_automation_id": 2,
|
||||
"with_name": 2
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\"",
|
||||
"\"UIColumnHeader\""
|
||||
],
|
||||
"control_type": "50031",
|
||||
"nodes": 7,
|
||||
"with_automation_id": 7,
|
||||
"with_name": 7
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"CabinetWClass\""
|
||||
],
|
||||
"control_type": "50032",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"Address Band Root\"",
|
||||
"\"Breadcrumb Parent\"",
|
||||
"\"CtrlNotifySink\"",
|
||||
"\"DUIListView\"",
|
||||
"\"DUIViewWndClassName\"",
|
||||
"\"Element\"",
|
||||
"\"HWNDView\"",
|
||||
"\"NUIPane\"",
|
||||
"\"NetUIHWND\"",
|
||||
"\"ProperTreeHost\"",
|
||||
"\"ReBarWindow32\"",
|
||||
"\"Search Box\"",
|
||||
"\"SearchBox\"",
|
||||
"\"SearchEditBoxWrapperClass\"",
|
||||
"\"SeparatorBand\"",
|
||||
"\"ShellTabWindowClass\"",
|
||||
"\"TravelBand\"",
|
||||
"\"UIRibbonCommandBar\"",
|
||||
"\"UIRibbonCommandBarDock\"",
|
||||
"\"UIRibbonWorkPane\"",
|
||||
"\"UniversalSearchBand\"",
|
||||
"\"UpBand\"",
|
||||
"\"WorkerW\""
|
||||
],
|
||||
"control_type": "50033",
|
||||
"nodes": 25,
|
||||
"with_automation_id": 25,
|
||||
"with_name": 25
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"UIViewHeader\""
|
||||
],
|
||||
"control_type": "50034",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50037",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
}
|
||||
],
|
||||
"crate": "uiautomation 0.25.0",
|
||||
"max_depth": 24,
|
||||
"node_count": 100,
|
||||
"os_build": "Microsoft Windows [Version 10.0.17763.7434]",
|
||||
"providers": [
|
||||
{
|
||||
"nodes": 10,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 10,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main(parent link):Microsoft: TreeView Item Proxy (unmanaged:UIAutomationCore.DLL)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 12,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 6,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIRibbon.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 15,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:DUI70.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 15,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:explorerframe.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 1,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main:Microsoft: Non-Control Proxy (unmanaged:uiautomationcore.dll); Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 22,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main:Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 1,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main:Microsoft: TreeView PRoxy (unmanaged:UIAutomationCore.DLL)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 1,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: Ribbon Non-Client Provider (unmanaged:UIRibbon.dll)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 2,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:DUI70.dll)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 1,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:explorerframe.dll)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 1,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Override(parent link):Nested [pid:<pid>,providerId:<providerid> Override(parent link):Unidentified Provider (unmanaged:DUI70.dll)]; Main:Microsoft: Non-Control Proxy (unmanaged:uiautomationcore.dll); Hwnd:Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 1,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Override(parent link):Nested [pid:<pid>,providerId:<providerid> Override(parent link):Unidentified Provider (unmanaged:DUI70.dll)]; Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main:Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]; Hwnd:Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 2,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Override:Nested [pid:<pid>,providerId:<providerid> Override(parent link):Unidentified Provider (unmanaged:DUI70.dll)]; Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main:Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
}
|
||||
],
|
||||
"sample": [
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 640.0,
|
||||
"width": 1000.0,
|
||||
"x": 40.0,
|
||||
"y": 40.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "CabinetWClass",
|
||||
"control_type": 50032,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 3,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": null,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: Ribbon Non-Client Provider (unmanaged:UIRibbon.dll)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 24.0,
|
||||
"width": 984.0,
|
||||
"x": 48.0,
|
||||
"y": 71.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "UIRibbonCommandBarDock",
|
||||
"control_type": 50033,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 15,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 0,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main:Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 22.0,
|
||||
"width": 44.0,
|
||||
"x": 86.0,
|
||||
"y": 45.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50021,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 20,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 5,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 24.0,
|
||||
"width": 22.0,
|
||||
"x": 86.0,
|
||||
"y": 43.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50000,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 10,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 6,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 23.0,
|
||||
"width": 884.0,
|
||||
"x": 104.0,
|
||||
"y": 71.0
|
||||
},
|
||||
"child_index": 3,
|
||||
"class_name": "",
|
||||
"control_type": 50018,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 11,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 5,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 23.0,
|
||||
"width": 884.0,
|
||||
"x": 104.0,
|
||||
"y": 71.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50025,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 0,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 11,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 23.0,
|
||||
"width": 58.0,
|
||||
"x": 104.0,
|
||||
"y": 71.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50019,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 4,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 12,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 22.0,
|
||||
"width": 650.0,
|
||||
"x": 155.0,
|
||||
"y": 101.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "msctls_progress32",
|
||||
"control_type": 50012,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 0,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 28,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main:Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 21.0,
|
||||
"width": 35.0,
|
||||
"x": 155.0,
|
||||
"y": 101.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50031,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 13,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 31,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 21.0,
|
||||
"width": 27.0,
|
||||
"x": 351.0,
|
||||
"y": 101.0
|
||||
},
|
||||
"child_index": 3,
|
||||
"class_name": "",
|
||||
"control_type": 50011,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 4,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 31,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIAutomationCore.DLL)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "SearchEditBox",
|
||||
"bounds": {
|
||||
"height": 15.0,
|
||||
"width": 170.0,
|
||||
"x": 824.0,
|
||||
"y": 104.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "SearchEditBox",
|
||||
"control_type": 50004,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 10,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 43,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:explorerframe.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "StatusBarModuleInner",
|
||||
"bounds": {
|
||||
"height": 23.0,
|
||||
"width": 984.0,
|
||||
"x": 48.0,
|
||||
"y": 649.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "StatusBarModuleInner",
|
||||
"control_type": 50017,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 10,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 52,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:DUI70.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "System.StatusBarViewItemCount",
|
||||
"bounds": {
|
||||
"height": 23.0,
|
||||
"width": 52.0,
|
||||
"x": 55.0,
|
||||
"y": 649.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "Element",
|
||||
"control_type": 50026,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 14,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 53,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:DUI70.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "PropertyValue",
|
||||
"bounds": {
|
||||
"height": 23.0,
|
||||
"width": 42.0,
|
||||
"x": 60.0,
|
||||
"y": 649.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "MetadataLabel",
|
||||
"control_type": 50020,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 7,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 54,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:DUI70.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "ViewMode_Details",
|
||||
"bounds": {
|
||||
"height": 23.0,
|
||||
"width": 22.0,
|
||||
"x": 984.0,
|
||||
"y": 649.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "SelectorButton",
|
||||
"control_type": 50013,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 7,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 56,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:DUI70.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "100",
|
||||
"bounds": {
|
||||
"height": 519.0,
|
||||
"width": 156.0,
|
||||
"x": 48.0,
|
||||
"y": 130.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "SysTreeView32",
|
||||
"control_type": 50023,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 9,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 59,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main:Microsoft: TreeView PRoxy (unmanaged:UIAutomationCore.DLL)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 24.0,
|
||||
"width": 72.0,
|
||||
"x": 91.0,
|
||||
"y": 146.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50024,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 12,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 60,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:UIAutomationCore.DLL); Main(parent link):Microsoft: TreeView Item Proxy (unmanaged:UIAutomationCore.DLL)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 519.0,
|
||||
"width": 824.0,
|
||||
"x": 208.0,
|
||||
"y": 130.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "UIItemsView",
|
||||
"control_type": 50008,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 10,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 71,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:explorerframe.dll)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 25.0,
|
||||
"width": 824.0,
|
||||
"x": 208.0,
|
||||
"y": 130.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "UIViewHeader",
|
||||
"control_type": 50034,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 6,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 72,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:DUI70.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "0",
|
||||
"bounds": {
|
||||
"height": 22.0,
|
||||
"width": 574.0,
|
||||
"x": 222.0,
|
||||
"y": 161.0
|
||||
},
|
||||
"child_index": 1,
|
||||
"class_name": "UIItem",
|
||||
"control_type": 50007,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 5,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 72,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:explorerframe.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 22.0,
|
||||
"width": 18.0,
|
||||
"x": 226.0,
|
||||
"y": 161.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "UIImage",
|
||||
"control_type": 50006,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 0,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 82,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Unidentified Provider (unmanaged:DUI70.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "TitleBar",
|
||||
"bounds": {
|
||||
"height": 31.0,
|
||||
"width": 968.0,
|
||||
"x": 64.0,
|
||||
"y": 43.0
|
||||
},
|
||||
"child_index": 3,
|
||||
"class_name": "",
|
||||
"control_type": 50037,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 0,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 0,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIRibbon.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "SystemMenuBar",
|
||||
"bounds": {
|
||||
"height": 22.0,
|
||||
"width": 22.0,
|
||||
"x": 48.0,
|
||||
"y": 48.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50010,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 6,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 94,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main(parent link):Microsoft: MSAA Proxy (unmanaged:UIRibbon.dll)]"
|
||||
}
|
||||
],
|
||||
"target_class": "CabinetWClass",
|
||||
"target_variant": "Windows 10 1809 ribbon Explorer folder window",
|
||||
"walk": {
|
||||
"cycles_skipped": 0,
|
||||
"enumeration_failures": 0,
|
||||
"nodes_observed": 100,
|
||||
"structurally_complete": true
|
||||
}
|
||||
}
|
||||
338
docs/plans/2026-07-27-002-captures/notepad-com.json
Normal file
338
docs/plans/2026-07-27-002-captures/notepad-com.json
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
{
|
||||
"capture": "uia-tree-census",
|
||||
"client_stack": "uia3-com",
|
||||
"control_types": [
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50000",
|
||||
"nodes": 7,
|
||||
"with_automation_id": 7,
|
||||
"with_name": 7
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50010",
|
||||
"nodes": 2,
|
||||
"with_automation_id": 2,
|
||||
"with_name": 2
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50011",
|
||||
"nodes": 6,
|
||||
"with_automation_id": 6,
|
||||
"with_name": 6
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50014",
|
||||
"nodes": 2,
|
||||
"with_automation_id": 2,
|
||||
"with_name": 2
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"msctls_statusbar32\""
|
||||
],
|
||||
"control_type": "50017",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50020",
|
||||
"nodes": 4,
|
||||
"with_automation_id": 4,
|
||||
"with_name": 4
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50027",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"Edit\""
|
||||
],
|
||||
"control_type": "50030",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"Notepad\""
|
||||
],
|
||||
"control_type": "50032",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
},
|
||||
{
|
||||
"class_names": [
|
||||
"\"\""
|
||||
],
|
||||
"control_type": "50037",
|
||||
"nodes": 1,
|
||||
"with_automation_id": 1,
|
||||
"with_name": 1
|
||||
}
|
||||
],
|
||||
"crate": "uiautomation 0.25.0",
|
||||
"max_depth": 24,
|
||||
"node_count": 26,
|
||||
"os_build": "Microsoft Windows [Version 10.0.17763.7434]",
|
||||
"providers": [
|
||||
{
|
||||
"nodes": 23,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main(parent link):Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 1,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main:Microsoft: EDIT Proxy (unmanaged:uiautomationcore.dll)]; Nonclient:Microsoft: Non-Client Proxy (unmanaged:uiautomationcore.dll); Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 1,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main:Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
},
|
||||
{
|
||||
"nodes": 1,
|
||||
"provider": "\"[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main:Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]; Nonclient:Microsoft: Non-Client Proxy (unmanaged:uiautomationcore.dll); Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]\""
|
||||
}
|
||||
],
|
||||
"sample": [
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 525.0,
|
||||
"width": 768.0,
|
||||
"x": 476.0,
|
||||
"y": 100.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "Notepad",
|
||||
"control_type": 50032,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 18,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": null,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main:Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]; Nonclient:Microsoft: Non-Client Proxy (unmanaged:uiautomationcore.dll); Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "15",
|
||||
"bounds": {
|
||||
"height": 443.0,
|
||||
"width": 752.0,
|
||||
"x": 484.0,
|
||||
"y": 151.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "Edit",
|
||||
"control_type": 50030,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 11,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 0,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main:Microsoft: EDIT Proxy (unmanaged:uiautomationcore.dll)]; Nonclient:Microsoft: Non-Client Proxy (unmanaged:uiautomationcore.dll); Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "NonClientVerticalScrollBar",
|
||||
"bounds": {
|
||||
"height": 426.0,
|
||||
"width": 17.0,
|
||||
"x": 1219.0,
|
||||
"y": 151.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50014,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 8,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 1,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main(parent link):Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "UpButton",
|
||||
"bounds": {
|
||||
"height": 17.0,
|
||||
"width": 17.0,
|
||||
"x": 1219.0,
|
||||
"y": 151.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50000,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 7,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 2,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main(parent link):Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 17.0,
|
||||
"width": 17.0,
|
||||
"x": 1219.0,
|
||||
"y": 577.0
|
||||
},
|
||||
"child_index": 2,
|
||||
"class_name": "",
|
||||
"control_type": 50027,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 0,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 1,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main(parent link):Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "1025",
|
||||
"bounds": {
|
||||
"height": 23.0,
|
||||
"width": 752.0,
|
||||
"x": 484.0,
|
||||
"y": 594.0
|
||||
},
|
||||
"child_index": 1,
|
||||
"class_name": "msctls_statusbar32",
|
||||
"control_type": 50017,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 0,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 0,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Main:Nested [pid:<pid>,providerId:<providerid> Annotation(parent link):Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main:Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]; Hwnd(parent link):Microsoft: HWND Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 21.0,
|
||||
"width": 376.0,
|
||||
"x": 484.0,
|
||||
"y": 596.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50020,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 0,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 9,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main(parent link):Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "TitleBar",
|
||||
"bounds": {
|
||||
"height": 28.0,
|
||||
"width": 736.0,
|
||||
"x": 500.0,
|
||||
"y": 103.0
|
||||
},
|
||||
"child_index": 2,
|
||||
"class_name": "",
|
||||
"control_type": 50037,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 0,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 0,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main(parent link):Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "SystemMenuBar",
|
||||
"bounds": {
|
||||
"height": 22.0,
|
||||
"width": 22.0,
|
||||
"x": 484.0,
|
||||
"y": 108.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50010,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 6,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 14,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main(parent link):Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
},
|
||||
{
|
||||
"automation_id": "",
|
||||
"bounds": {
|
||||
"height": 22.0,
|
||||
"width": 22.0,
|
||||
"x": 484.0,
|
||||
"y": 108.0
|
||||
},
|
||||
"child_index": 0,
|
||||
"class_name": "",
|
||||
"control_type": 50011,
|
||||
"failed_reads": 0,
|
||||
"is_offscreen": false,
|
||||
"is_password": false,
|
||||
"name": {
|
||||
"chars": 6,
|
||||
"present": true
|
||||
},
|
||||
"parent_index": 15,
|
||||
"provider_description": "[pid:<pid>,providerId:<providerid> Annotation:Microsoft: Annotation Proxy (unmanaged:uiautomationcore.dll); Main(parent link):Microsoft: MSAA Proxy (unmanaged:uiautomationcore.dll)]"
|
||||
}
|
||||
],
|
||||
"target_class": "Notepad",
|
||||
"target_variant": "classic Win32 Notepad (%WINDIR%\\System32\\notepad.exe), not the Store MSIX RichEdit variant",
|
||||
"walk": {
|
||||
"cycles_skipped": 0,
|
||||
"enumeration_failures": 0,
|
||||
"nodes_observed": 26,
|
||||
"structurally_complete": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
---
|
||||
title: UIA Element Wrapper & Tree Walk (Sub-phase 2.2) - Plan
|
||||
type: feat
|
||||
date: 2026-07-27
|
||||
origin: docs/phases.md
|
||||
artifact_contract: ce-unified-plan/v1
|
||||
artifact_readiness: implementation-ready
|
||||
product_contract_source: docs/phases.md §Phase 2 sub-phase 2.2
|
||||
execution: code
|
||||
---
|
||||
|
||||
# UIA Element Wrapper & Tree Walk (Sub-phase 2.2) - Plan
|
||||
|
||||
## Goal Capsule
|
||||
|
||||
- **Objective:** Own a UIA element wrapper and a raw tree walk that 2.4 can wire into the snapshot engine without reshaping it — proven against a window the test process creates itself, in a second process, because the two apps this sub-phase's exit criteria name cannot be asserted on the CI runner.
|
||||
- **Authority hierarchy:** `docs/phases.md` §2.2 > `probes/windows/FINDINGS.md` (for `api-contract` rows, and for `app/provider` rows only where the row records its environment dependency explicitly) > this plan > implementer judgment. Where measured evidence contradicts the document, U9 amends the document in this same PR, per the source-of-truth feedback rule in the Platform Delivery Model.
|
||||
- **Stop conditions:** Do not wire `ObservationOps::observe_tree` — that is 2.4. Do not implement roles, states, `native_id`, or name evidence content — that is 2.3. Do not implement `resolve.rs`, `surfaces.rs`, the web-wrapper predicate body, or Chromium detection — 2.4/2.5. Do not allocate refs. Do not register a self-hosted runner. If U1 returns an answer this plan did not anticipate, take the pre-committed branch in U1 rather than reverting to inference.
|
||||
- **Execution profile:** One PR into `feat/windows-adapter`, never `main`. Budget ≈2.5-3k lines of hand-written Rust across fourteen files; committed JSON captures, the PowerShell probe, and the workflow YAML are evidence artifacts and are excluded from that figure, matching how sub-phase 2.0's 21k-insertion probe corpus was accounted. Whether evidence artifacts count against the Platform Delivery Model's 2,000-changed-line cap is an Open Question, not an assumption. Conventional Commits.
|
||||
- **Tail ownership:** The implementer opens the PR against `feat/windows-adapter` and reports the Verification Contract results.
|
||||
|
||||
---
|
||||
|
||||
## Product Contract
|
||||
|
||||
### Summary
|
||||
|
||||
Sub-phase 2.1 landed the Windows toolchain, an MTA apartment, and a private-file layer; `crates/windows/src/tree/` is an empty module. 2.2 fills it with the four things every later observation sub-phase consumes: an element wrapper with sound ownership, a UIA client and window-root resolver constructed against the apartment 2.1 already owns, a tree walk with a cycle guard and honest error classification, and a `CacheRequest` layer that batches only when batching pays. It ships a tree-dump example and committed COM dumps as dev-box evidence, and it corrects five statements in `docs/phases.md` that 2.0's ledger and this plan's research disprove.
|
||||
|
||||
### Problem Frame
|
||||
|
||||
The walker is not the hard part. Five things make this sub-phase easy to get silently wrong:
|
||||
|
||||
**The crate's own child enumeration is unsafe for a snapshot.** `uiautomation::UITreeWalker::get_children` is `while let Ok(next) = self.get_next_sibling(¤t)`, which swallows end-of-siblings and a cross-process RPC failure through the same arm. A hung target yields a truncated tree with no error. End-of-list arrives as `Err`, not `Option`, and the only discriminator is `Error::result()` — a mechanical claim that must be **measured against the real crate**, not encoded from a reading of its source, because a hand-built fake would only ever confirm the implementer's model of it.
|
||||
|
||||
**UIA has no per-property error channel.** macOS gets a parallel array where an absent slot is `kCFNull` and a failed slot carries the per-attribute error (`crates/macos/src/tree/node_attribute_decode.rs:19-39`). UIA has neither: an unavailable property returns a `UiaGetReservedNotSupportedValue()` sentinel that must be compared by pointer identity, and `VT_EMPTY` is ambiguous between "absent" and "not implemented". Core's `LocatorField::{Known, Absent, Unknown}` distinction is load-bearing — `Unknown` fails `EvidenceRequirements::satisfies()` and blocks projection, `Absent` is legitimate. Collapsing the two degrades completeness gating silently.
|
||||
|
||||
**Both named exit-criteria targets are untestable in CI.** `windows-latest` is Server 2025 (ledger C-11), Windows 11 24H2-based, shipping the Win11 shell; the dev box is Server 2019 build 17763 with the Win10-1809 ribbon Explorer. Every 2.0 tree row is `scope: app/provider`, which the probe corpus's own scope rule (`FINDINGS.md` KTD7 — a probe row may outrank `docs/phases.md` only when its scope is `api-contract`) keeps from travelling. Worse, the committed dumps are **managed-stack** while this sub-phase ships a UIA3 **COM** client: A2-4 measured the identical Notepad window as 3 nodes managed and 26 nodes COM. And on Win11 24H2 an app-execution-alias reparse point redirects even an explicit `C:\Windows\System32\notepad.exe` to the Store RichEdit app, so a test can walk the wrong Notepad and never learn it did.
|
||||
|
||||
**A test process automating its own window never crosses a process boundary.** A self-created window is served by in-process client-side providers, so the failure taxonomy the walker exists to classify — RPC failure versus exhaustion, a blocking `WM_GETOBJECT`, a target that stops pumping — is structurally unreachable from an in-process fixture, and a cache policy validated against it is validated against exactly the provider class that policy says to skip. The fixture must therefore be hostable in a **second process**.
|
||||
|
||||
**Five of this sub-phase's own scope items have zero measured evidence.** 2.0 could not observe refcounts (both its stacks were CLR-managed), never exercised a cycle, never read an uncached property off a cached element, never moved an element across threads, and never killed a target mid-walk — leaving no HRESULT mapping for any UIA failure, which Invariant 8's `platform_detail` format needs.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **R1.** A CI capability probe converts the runner-environment inferences and the end-of-list discriminator into measured evidence before any unit that depends on them is written, with a pre-committed action for every answer including "unmeasurable".
|
||||
- **R2.** `uiautomation` and the `windows-sys` additions enter `crates/windows` only, target-gated, without tripping the core-isolation gates or the 15 MiB binary cap.
|
||||
- **R3.** A `UIAElement` wrapper owns element identity for the crate: inner field unreachable outside the module, no `Copy`, by-value conversion into `NativeHandle`, and a downcast guard that rejects a foreign payload.
|
||||
- **R4.** The UIA client is constructed without initialising COM, on a thread model Microsoft's threading guidance permits, and a **production** window-root resolver maps an HWND to a root element with U1's measured HRESULT encoded into the error mapping.
|
||||
- **R5.** Property reads distinguish `Known`, `Absent`, and `Unknown`; every UIA property id comes from the crate's generated constants; and no value-bearing property is read from an element whose `IsPassword` is true.
|
||||
- **R6.** The tree walk uses its own child-enumeration loop that classifies end-of-list separately from failure, guards cycles on an ancestor path, bounds raw and logical depth independently through a seam that lets them diverge, and never marks a truncated tree complete.
|
||||
- **R7.** `CacheRequest` batching is conditional on a signal available before the walk, keeps `ElementMode::Full`, and its correctness — not its timing — is asserted against an out-of-process provider.
|
||||
- **R8.** A tree-dump example produces COM dumps of Notepad and Explorer, committed as dev-box evidence with host identifiers normalised, recording the target variant and client stack.
|
||||
- **R9.** Every assertion that runs in CI is provider-independent; no test asserts a node count, tree shape, timing multiplier, or any other `app/provider` fact.
|
||||
- **R10.** Statements in `docs/phases.md` that this sub-phase's evidence disproves are corrected in place, in this PR.
|
||||
- **R11.** The walk's output is constructible into core's `ObservedSubtree`/`ObservedTree` without a translation layer in 2.4, with every `LocatorEvidence` slot populated per R5 and the tri-state preserved.
|
||||
- **R12.** No error raised by the walk, a property read, or the client carries app-derived content — only shape.
|
||||
|
||||
### Key Decisions
|
||||
|
||||
- **The UIA client is constructed by direct `CoCreateInstance`, never through `UIAutomation::new()`.** (session-settled: user-directed — the stated instruction named `UIAutomation::new_direct()`; implementation showed that constructor builds `CUIAutomation`, which has no `IUIAutomation2` and therefore no call timeout, so `ElementFromHandle` against a window that stopped dispatching blocks indefinitely (A14-11, A14-12). The prohibition is kept and the property that motivated it is kept — the same direct `CoCreateInstance`, never `CoInitializeEx` — while the CLSID moves to `CUIAutomation8` so the calls can be bounded.) Governs R4. See KTD1.
|
||||
- **The element wrapper preserves `AXElement`'s encapsulation invariants but delegates refcounting to `windows-core`.** (session-settled: user-approved — the stated instruction was "Clone must AddRef, Drop must Release"; source verification showed the crate already does exactly that, so hand-writing it would double-release. The invariant is kept; the mechanism is delegated.) Governs R3.
|
||||
- **The cycle guard is an ancestor path, never a global visited set.** (session-settled: user-directed.) Governs R6. The macOS rationale does not transfer — see KTD4.
|
||||
- **No test hardcodes a machine-specific fact.** (session-settled: user-directed.) Governs R9.
|
||||
|
||||
### Scope Boundaries
|
||||
|
||||
- **Out:** `ObservationOps::observe_tree` wiring, `get_tree`/`get_subtree`, `list_windows`, `list_apps`, `focused_window`, `list_displays`, surface detection, the web-wrapper predicate **body**, Chromium detection, resolver depth — all 2.4 (`docs/phases.md:1026-1040`). 2.2 ships the seam the predicate plugs into (KTD10), not the predicate.
|
||||
- **Out:** role mapping, state vocabulary, `AutomationId` → `native_id`, name evidence — 2.3 (`docs/phases.md:1008-1018`).
|
||||
- **Out:** element re-identification from `RefEntry` — 2.5.
|
||||
- **Out:** any action, pattern invocation, or input synthesis — 2.6+. The walk never calls `SetFocus`: A3-4 measured that `SetFocus` moved the desktop foreground, so it is not headless.
|
||||
- **Out:** ref allocation of any kind. `crates/core/src/ref_alloc.rs::allocate_refs` is the only recursive allocator in the product.
|
||||
- **Out:** self-hosted runner registration — 2.12. This plan records a measured fact about the hosted runner (U9 amendment 5) but does not restate 2.12's justification; that belongs to 2.12's own plan and its own evidence.
|
||||
- **Deferred to follow-up:** capturing the shared "mirror a platform crate's memory- and cycle-safety pattern" lesson as a `docs/solutions/` entry once both adapters exist.
|
||||
|
||||
---
|
||||
|
||||
## Planning Contract
|
||||
|
||||
### Key Technical Decisions
|
||||
|
||||
- **KTD1. `new_direct()` — the recorded reason is wrong, the decision is right.** Governs R4. `docs/phases.md:999` says `new()` "would re-initialise the apartment 2.1 established". Source verification of 0.25.0 shows otherwise: `new()` calls `CoInitializeEx(None, COINIT_MULTITHREADED)` and proceeds when `HRESULT::is_ok()`, which is `>= 0`. On a thread already in the MTA that returns `S_FALSE` (1) — success, with the apartment's init count incremented. `UIAutomation` has no `Drop` and the crate never calls `CoUninitialize`, so each `new()` permanently leaks one initialisation count. The *hard* failure is the **STA** case: `RPC_E_CHANGED_MODE` is negative, so `new()` returns `Err` on any host thread already in an STA. Two real reasons to construct directly rather than through `new()`: it works inside an STA host, and it leaks nothing in a long-lived process (Phase 5's daemon). U9 amendment 3 corrects the document.
|
||||
**`new_direct()` itself proved insufficient, for a reason outside this decision's frame.** It is `CoCreateInstance(&CUIAutomation, …)`, and that object returns `E_NOINTERFACE` for `IUIAutomation2`, where `SetConnectionTimeout` lives — so every call through it is unbounded, and `ElementFromHandle` against a window whose thread stopped dispatching blocks rather than timing out (A14-11: 59.09 s through a 30 s watchdog, against `UIA_E_TIMEOUT` in 2.05 s once the timeout is set; A14-12 records that the crate's client cannot reach the setter). The shipped client is `CoCreateInstance(&CUIAutomation8, …)` with `ConnectionTimeout` set, and there is no fallback to the unbounded one — a client whose calls cannot be bounded is refused. Both properties above survive unchanged, and `UIAutomation::new()` is still never called.
|
||||
|
||||
- **KTD2. The wrapper keeps `AXElement`'s encapsulation and delegates its refcounting.** Governs R3. Verified from 0.25.0 source: `pub struct UIElement { element: IUIAutomationElement }` derives `Clone` and has **no `Drop` impl** — `windows-core`'s generated COM types implement `Clone` as `AddRef` and `Drop` as `Release`. Adding a hand-written `Drop` would double-release. What transfers from `crates/macos/src/tree/ax_element.rs:7-30` is the encapsulation: inner field `pub(crate)`, no `Copy`, no raw accessor, `into_native_handle(self)` by value. Thread affinity comes free — `UIElement`, `UIAutomation`, `UITreeWalker`, `UICacheRequest`, `Handle` are all `!Send + !Sync` — so no `PhantomData` and **no `unsafe impl Send`** anywhere in this sub-phase.
|
||||
|
||||
- **KTD3. The walk owns its enumeration loop, and the discriminator is measured, not reasoned.** Governs R1, R6. `UITreeWalker::get_children` cannot distinguish exhaustion from failure and is banned. The replacement rests on: windows-rs `Type::from_abi` returns `Err(Error::empty())` on a null out-param, and `windows-result` 0.4.1's `Error::empty()` sets a sentinel reporting `HRESULT(0)`; so `err.result().is_none()` should mean benign end-of-list and `Some(negative_hr)` a real failure. **That chain has one unverified link** (`uiautomation`'s `From<windows_result::Error>` body). If it is inverted, every failure classifies as benign and a truncated tree reports complete. U1 measures the real `code()`/`result()` pair at exhaustion and at a forced failure; U6 depends on U1 and asserts completeness live, so an inverted discriminator fails a test rather than passing silently.
|
||||
|
||||
- **KTD4. The cycle guard is kept; its stated rationale and its key both change.** Governs R6. `docs/phases.md:995` justifies the guard as "mirrors macOS: reused pointers across sibling branches" — a macOS mechanism imported wholesale. 2.0 measured nothing about UIA element identity reuse; neither 2.0 walker even calls `GetParent`. The guard is still correct, for a different reason: UIA returns a *new* `IUIAutomationElement` proxy per query, so pointer identity is meaningless. The key becomes `get_runtime_id() -> Result<Vec<i32>>` with `compare_elements` as fallback. The *semantics* port unchanged from `crates/macos/src/tree/query/traversal.rs`: insert on entry (`:73-78`), remove on **every** exit path (`:82`, `:120`, `:157`). U9 amendment 2 corrects the rationale.
|
||||
|
||||
- **KTD5. Batching is conditional on provider class, not node count.** Governs R7. `docs/phases.md:1003` requires the dump binary print both trees "with batched reads". A6-2 measured batching on classic Notepad as a **pessimization** (0.5763x overall, find phase 0.436x) because Notepad is served by `UIAutomationClientsideProviders` *inside the client process*, so an uncached read costs no cross-process RPC. A6-1 measured Explorer at 2.69x overall, below the documented 3-5x, with the find phase 1.5x *slower* and the read phase ~298x faster. The API Mapping table at `:1106` already records this; §2.2 was never amended, so the two disagree.
|
||||
**A node-count threshold is not implementable and is deliberately excluded:** the cache request must be built *before* the walk, but node count is known only *after* it, so the arm would have no input at decision time — an implementer would either add a `FindAll` probe per subtree (eroding the win A6-1 measured, whose find pass was already slower) or wire it to a constant. The constant is also uncalibrated: A6-2 is `managed`-stack and `app/provider`-scoped, and A2-4 measured that same window as 3 nodes managed against 26 COM, so a "3 nodes" threshold is roughly 9x off for the COM client this sub-phase ships. Policy: cache only properties that will be read, and skip the cache when a root-level `ProviderDescription` read indicates an in-process client-side provider — the mechanism, which generalises, rather than the number, which does not. The node-count crossover moves to Open Questions.
|
||||
|
||||
- **KTD6. `ElementMode::Full` always; `TreeScope` is not bitflags.** Governs R7. Microsoft: with `AutomationElementMode_None` "you do not have access to any uncached properties and control patterns" and "cannot call methods that perform actions on the control, such as `Invoke`" — which would break every command from 2.6 onward. Separately, `uiautomation::types::TreeScope` is a plain enum (`Element=1, Children=2, Descendants=4, Parent=8, Ancestors=16, Subtree=7`) with **no bitwise operators**; only `Subtree` is pre-combined. Microsoft warns the scope is relative to the retrieved element, so omitting `Element` silently fails to cache the root's own properties. Whether `TreeScope::try_from(3)` succeeds is settled by a U7 test, not an assumption.
|
||||
|
||||
- **KTD7. Property ids come from generated constants, never literals.** Governs R5. A2-5 measured that pattern-availability property ids are **build-specific** — `IsAnnotationPatternAvailable` is 30118 on build 17763, while 30113 is a different property — and the row names 2.2 explicitly: a hand-written table is "the single most likely silent failure in a Rust pattern-availability check". Use `uiautomation::types::UIProperty` and `UIPatternType`. The internal-set → `UIProperty` mapping is an exhaustive `match` with no catch-all arm.
|
||||
|
||||
- **KTD8. CI asserts invariants against a self-created window hosted out-of-process; the named apps are dev-box evidence.** Governs R8, R9. Three independent blockers make app-based CI assertions unsound: SKU (Server 2025's Win11 shell vs the dev box's 2019 ribbon Explorer; A10-7 records this box cannot present the modern shells), stack (A2-4's 26-vs-3 divergence), and the probe corpus's scope rule (`FINDINGS.md` KTD7). None of those three depends on the runner-session inference, so this conclusion stands independently of the weakest evidence in this plan. The Win11 24H2 app-execution-alias makes the Notepad trap silent. **The fixture is hosted in a child process** so the walk crosses a real process boundary; an in-process variant is retained only for teardown and concurrency tests. Any incidental app touch in CI asserts at most "a Window root resolves with >0 descendants".
|
||||
|
||||
- **KTD9. The fixture window's threading is prescribed, not incidental.** Governs R4, R9. Microsoft's UIA threading guidance: a client automating its own UI "should make all UI Automation calls from a separate thread… This thread should not own any windows, and should be a Multithreaded Apartment (MTA) model thread". `ElementFromHandle` sends `WM_GETOBJECT`, and a cross-thread `SendMessage` blocks until the receiving thread pumps, so the fixture's window thread must pump for the whole call. The window must be genuinely visible with a non-zero rect: `HwndProxyElementProvider` excludes windows failing `IsWindowVisible` or having a zero-area rect, and sibling navigation on such a window raises `ElementNotAvailable`. Off-screen positioning is fine; `SW_HIDE` is not. Message-only windows are unsupported — no evidence exists either way, and they would fail the visibility rule.
|
||||
|
||||
- **KTD10. Three seams, called but not implemented.** Governs R5, R6, and the 2.3/2.4 boundary. The macOS traversal calls two vocabulary functions — `roles::ax_role_and_subrole_to_str` (`crates/macos/src/tree/query/node_read.rs:89-94`) and `action_list::read_platform_available_actions` (`:142-150`). 2.2 calls two Windows analogues returning `"unknown"` and an empty action list, so 2.3 fills bodies without touching traversal. A **third** seam is required for R6 to be testable: on macOS the only thing that makes logical depth diverge from raw depth is the web-wrapper predicate, which is deferred to 2.4 — so without a seam, U6's divergence test asserts behaviour 2.2 ships no mechanism to produce. 2.2 defines `is_web_wrapper(&UIAElement) -> bool` returning `false`, called where child logical depth is computed, so the fake can force divergence now and 2.4 fills the body without editing `walker.rs`.
|
||||
|
||||
- **KTD11. Non-Windows twins are mandatory, for tree files and platform-crate examples alike.** Governs R2. Every macOS tree file carries a `#[cfg(not(target_os = "macos"))] mod imp` mirror, and 2.1 established the convention at `crates/windows/src/system/com_runtime.rs:149-175`. CI's `platform-check` matrix only checks each crate on its native OS, so a missing twin passes CI and breaks the documented local workspace commands. This extends to `examples/`: `cargo check --all-targets` compiles them, and an example referencing the target-gated `uiautomation` breaks the Linux gate. `crates/macos/examples/ax_probe.rs` is the precedent — every item gated, plus a `#[cfg(not(...))] fn main()` stub.
|
||||
|
||||
- **KTD12. The walker takes an arbitrary root element.** Governs R6. `ElementFromHandle` is one root source, not the entry signature. 2.5's drill-down must re-enter at a stored ref, and the progressive-snapshot contract requires drill-down to reuse the single traversal rather than fork a second one.
|
||||
|
||||
- **KTD13. The secure-field gate lands in 2.2, not 2.3.** Governs R5. 2.2 ships the property set *and* the `CacheRequest` that decide what is fetched from a foreign process, so deferring the gate forces 2.3 to retrofit it into an already-built batch or pay the second round trip KTD5 exists to avoid. macOS solves it in exactly this layer — `safe_attribute_mask` and `should_read_value` at `crates/macos/src/tree/node_attribute_names.rs:137-163`, pinned by its own regression test. `UIA_IsPasswordPropertyId` rides in the same cache request as the properties it gates, and every value-bearing property returns `Absent` when it is true. The claim that "UIA already refuses `ValuePattern.Value`" covers one pattern and says nothing about `Name`, `HelpText`, or `LegacyIAccessible.Value`; it is unverified and moves into U1's measurement list.
|
||||
|
||||
- **KTD14. Errors carry shape, never app-derived content.** Governs R12. The repo codified this at `docs/solutions/conventions/keep-raw-arguments-out-of-trace-reachable-error-messages.md`, and the mechanism is live: `crates/core/src/ref_action.rs:238` clones `error.message` and `:289` clones `err.details` into `actionability.check.error`, which reaches session JSONL segments and `trace export` HTML. Once 2.4/2.6 wire these readers behind `get_live_*`, any element `Name`, `ClassName`, or property value baked into a walker error string is persisted. Errors carry the HRESULT, its symbolic name, the property id, node depth, child index, and character counts — never the value, `Name`, `ClassName`, window title, or `ProviderDescription`.
|
||||
|
||||
### High-Level Technical Design
|
||||
|
||||
Where 2.2 sits, and what it deliberately does not touch:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph core["agent-desktop-core (untouched by 2.2)"]
|
||||
OT["ObservedSubtree / ObservedTree<br/>LocatorEvidence · LocatorField"]
|
||||
RA["ref_alloc::allocate_refs<br/>(the only ref allocator)"]
|
||||
OT --> RA
|
||||
end
|
||||
subgraph win22["crates/windows/src/tree/ — 2.2"]
|
||||
AU["automation.rs<br/>bounded client · root resolver · error classifier"]
|
||||
EL["element.rs<br/>UIAElement"]
|
||||
PR["properties.rs / property_ids.rs<br/>Known · Absent · Unknown · IsPassword gate"]
|
||||
WK["walker.rs<br/>own loop · cycle guard · depth seam"]
|
||||
CA["cache.rs<br/>provider-class batching"]
|
||||
AU --> EL
|
||||
EL --> WK
|
||||
PR --> WK
|
||||
CA --> WK
|
||||
end
|
||||
subgraph later["2.3 / 2.4 / 2.5"]
|
||||
V["roles · states · native_id"]
|
||||
OB["observe_tree wiring"]
|
||||
RS["resolve · surfaces"]
|
||||
end
|
||||
WK -. "3 seams (KTD10)" .-> V
|
||||
WK -. "consumed by" .-> OB
|
||||
EL -. "consumed by" .-> RS
|
||||
WK -->|"emits ObservedSubtree (R11)"| OT
|
||||
```
|
||||
|
||||
The walk's error classification, which is the correctness core:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
N["get_next_sibling(el)"] --> R{"Result"}
|
||||
R -->|Ok| C["child retained"]
|
||||
R -->|"Err, result() == None"| E["end of list<br/>subtree complete"]
|
||||
R -->|"Err, result() == Some(hr)"| F["real COM failure<br/>mark incomplete + structured error"]
|
||||
E -.->|"discriminator measured in U1,<br/>asserted live in U6"| N
|
||||
```
|
||||
|
||||
### Output Structure
|
||||
|
||||
```
|
||||
probes/windows/
|
||||
├── 14-ci-capability/ # U1: probe + captures
|
||||
└── FINDINGS.md # U1: appended rows
|
||||
.github/workflows/
|
||||
└── windows-capability-probe.yml # U1: pull_request(paths) + workflow_dispatch
|
||||
crates/windows/
|
||||
├── Cargo.toml # U2: uiautomation; windows-sys feature add
|
||||
├── examples/
|
||||
│ └── uia_tree_dump.rs # U8: dev-box dump tool, fully cfg-gated
|
||||
└── src/tree/
|
||||
├── mod.rs
|
||||
├── element.rs # U2
|
||||
├── automation.rs # U3: client, root resolver, error classifier
|
||||
├── fixture.rs # U4: #[cfg(test)] fixture, child-process host
|
||||
├── properties.rs # U5
|
||||
├── property_ids.rs # U5
|
||||
├── walker.rs # U6 (split if it nears 400 lines)
|
||||
├── cache.rs # U7
|
||||
└── *_tests.rs # sibling test modules per repo convention
|
||||
docs/phases.md # U9: five in-place corrections
|
||||
```
|
||||
|
||||
Per-unit `**Files:**` lists are authoritative; this tree is a scope declaration.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Measure the runner and the discriminator before designing against them
|
||||
|
||||
- **Goal:** Convert the inferences this plan rests on into measured evidence, with a pre-committed action for every possible answer.
|
||||
- **Requirements:** R1, R9.
|
||||
- **Dependencies:** none. Runs before any Rust in this PR is written.
|
||||
- **Files:** `probes/windows/14-ci-capability/probe.ps1`, `probes/windows/14-ci-capability/probe.rs`, `probes/windows/14-ci-capability/captures/*.json`, `.github/workflows/windows-capability-probe.yml`, `probes/windows/FINDINGS.md`.
|
||||
- **Approach:** **The trigger is `pull_request` with a path filter, plus `workflow_dispatch`.** GitHub triggers `workflow_dispatch` only for workflow files present on the **default branch**, which is `main` — and this PR must never touch `main`. A `pull_request` event runs the workflow from the PR head ref, so the probe executes on this sub-phase's own PR; the retained `workflow_dispatch` becomes usable once `feat/windows-adapter` merges. This lands as a standing workflow rather than a one-off script because the Platform Delivery Model lets later sub-phases extend the probe corpus, and 2.3-2.10 each face the same measure-before-design need; the file is path-filtered so it adds no time to the required lanes.
|
||||
Measure, as committed JSON: session id and `qwinsta`; `GetProcessWindowStation` and `[Environment]::UserInteractive`; **whether a window created by the probe process itself is visible, non-zero-rect, and UIA-walkable from a second MTA thread** — the single fact U4's entire strategy rests on; and, from a small Rust probe against the real crate, **the exact `code()`/`result()` pair returned at sibling exhaustion and at a forced enumeration failure** (KTD3), plus whether `ValuePattern.Value`, `Name`, and `HelpText` return content on an `ES_PASSWORD` control (KTD13).
|
||||
**Pre-committed actions.** If the discriminator is inverted or ambiguous, U6 classifies on the measured pair and the fake enumerators are rebuilt from it. If the runner cannot host a walkable window, U2/U3/U5's non-window tests still land in this PR, while U4, U6's live smoke, and U7's cache correctness move to 2.12 behind the self-hosted interactive runner, and the Verification Contract records R5/R7 as **unmet**, not green. "Unmeasurable" is a branch, never a silent revert to inference.
|
||||
- **Execution note:** Given the repo's history — 1,062 LOC deleted for shipping platform code CI could not execute — writing the Verification Contract on inference when a ten-minute probe settles it is the same bet.
|
||||
- **Patterns to follow:** `probes/windows/` corpus structure and `FINDINGS.md` row format from 2.0; `.github/workflows/native-e2e.yml` for a non-required Windows-capable job.
|
||||
- **Test scenarios:**
|
||||
- The workflow runs on this PR without any change to `main`, and its path filter confines it to the probe directory and its own workflow file.
|
||||
- Every probe output is committed as JSON beside the script and is re-runnable.
|
||||
- Each appended `FINDINGS.md` row carries a `scope:` value; runner-environment rows also record the image version.
|
||||
- `ProviderDescription` values in committed output are normalised (see U8's rule).
|
||||
- **Verification:** the workflow completes on `windows-latest`; captures are committed; the three Open Questions U1 owns (`TreeScope::try_from(3)` is settled in U7 instead — see below), the end-of-list discriminator, the self-created-window walkability, and the `IsPassword` leak question are each answered or explicitly recorded as still-unknown **with their pre-committed branch taken**; and the runner-environment inferences in the first Risks bullet are replaced by committed rows.
|
||||
|
||||
### U2. Add the UIA dependencies and the element wrapper
|
||||
|
||||
- **Goal:** `crates/windows` can hold a UIA element safely, and the new dependencies do not reach core or blow the size cap.
|
||||
- **Requirements:** R2, R3.
|
||||
- **Dependencies:** none.
|
||||
- **Files:** `crates/windows/Cargo.toml`, `Cargo.lock`, `crates/windows/src/tree/mod.rs`, `crates/windows/src/tree/element.rs`, `crates/windows/src/tree/element_tests.rs`.
|
||||
- **Approach:** Add `uiautomation = "0.25"` under `[target.'cfg(target_os = "windows")'.dependencies]`. Pin its features deliberately: defaults are `control` + `input` (transitively `pattern`); `process`, `clipboard`, `screenshot`, `event` are not default and belong to later sub-phases.
|
||||
Extend the **existing** `windows-sys` dependency with `Win32_UI_WindowsAndMessaging` — U4's fixture needs `RegisterClassExW`, `CreateWindowExW`, `ShowWindow`, `GetMessageW`, `DispatchMessageW`, and 2.1's feature list lacks them. Prefer `windows-sys` because it is already linked; do not reach for the `windows` crate for these. Add `windows` itself only if a symbolic HRESULT table needs it — the pure UIA path does not, because `uiautomation::types::Handle` implements `From<isize>`. These are three distinct crates: `uiautomation`'s `control` feature enables `windows/Win32_UI_WindowsAndMessaging`, which is not the `windows-sys` feature above. If `windows` is added, its version must unify with `^0.62.2`.
|
||||
Define `pub(crate) struct UIAElement(pub(crate) uiautomation::UIElement)` — `pub(crate)` inner, no `Copy`, no raw accessor, no hand-written `Clone`/`Drop` (KTD2). Provide `into_native_handle(self) -> NativeHandle` and a `uia_element(handle: &NativeHandle) -> Result<&UIAElement, AdapterError>` downcast guard mirroring `crates/macos/src/adapter.rs:15-31`. Add the non-Windows twin (KTD11).
|
||||
- **Patterns to follow:** `crates/macos/src/tree/ax_element.rs:7-30` for encapsulation shape (not `Clone`/`Drop`); `crates/macos/src/adapter.rs:15-31` and its tests at `:38-58`; `crates/windows/src/system/com_runtime.rs:149-175` for the twin convention.
|
||||
- **Test scenarios:**
|
||||
- A wrapper converts into a `NativeHandle` and downcasts back to the same element.
|
||||
- The downcast guard rejects a null handle with `InvalidArgs`.
|
||||
- The downcast guard rejects a foreign payload — `NativeHandle::new(String::from("ax-token"))` must not masquerade as a `UIAElement`.
|
||||
- Cloning a wrapper and dropping both leaves the survivor usable — assert observable behaviour (a property read still succeeds), **not** `include_str!` source text; the macOS precedent at `crates/macos/src/tree/element_tests.rs:32-45` asserts on source text, which this repo's own learning calls not-a-test.
|
||||
- `cargo tree -p agent-desktop-core --edges normal,build,dev` unchanged on both host and MSVC targets.
|
||||
- **Verification:** the widened core-isolation gate passes; the release binary stays under 15 MiB; the crate compiles for a non-Windows target.
|
||||
|
||||
### U3. Construct the client, the root resolver, and the error classifier
|
||||
|
||||
- **Goal:** A UIA client that never initialises COM, a production HWND-to-root resolver, and one error classifier everything downstream shares.
|
||||
- **Requirements:** R4, R12.
|
||||
- **Dependencies:** U1, U2.
|
||||
- **Files:** `crates/windows/src/tree/automation.rs`, `crates/windows/src/tree/automation_tests.rs`, `crates/windows/src/tree/mod.rs`.
|
||||
- **Approach:** A client accessor constructing directly with `CoCreateInstance(&CUIAutomation8, …)` and setting `ConnectionTimeout`, never `UIAutomation::new()`; 2.1's bootstrap already guarantees the COM precondition, so the accessor asserts rather than establishes it and returns a structured error if COM is uninitialised (`CO_E_NOTINITIALIZED`). It also serializes the first UI Automation call across threads: the client core initialises lazily and not re-entrantly, and concurrent first touches fail with `E_FAIL` "Re-Entrant CheckInit() call, aborting" (A14-13).
|
||||
**The root resolver is production code, not fixture code** — `pub(crate) fn root_from_hwnd(hwnd: isize, deadline) -> Result<UIAElement, AdapterError>` wrapping `element_from_handle`, encoding U1's measured HRESULT into the `WINDOW_NOT_FOUND` versus `ELEMENT_NOT_FOUND` mapping. The origin's §2.2 scope names "`ElementFromHandle` roots for window entry" as a deliverable; without this, 2.4 re-implements it inside its own budget.
|
||||
Build the shared error classifier here: `uiautomation::Error` mixes its own positive sentinels (`ERR_NONE=0`, `ERR_NOTFOUND=1`, `ERR_TIMEOUT=2`, …) into the same `i32` as HRESULTs, so `code()` alone is ambiguous — branch on `result()`. `Some(hr)` formats via the **existing** helper; `None` maps the sentinel to an `ErrorCode` and must not print a fabricated HRESULT. Every message obeys KTD14.
|
||||
- **Execution note:** Write the classifier tests first. The sentinel-versus-HRESULT ambiguity is the branch a happy-path implementation gets wrong, and every later unit inherits it.
|
||||
- **Patterns to follow:** `crates/windows/src/system/com_runtime.rs` for the `OnceLock`-guarded accessor and `#[cfg]`-split `mod imp`; `crates/windows/src/system/permissions.rs:133-139` — `com_hresult_detail` already emits `COM HRESULT 0x{code:08X} (E_ACCESSDENIED: Access is denied)` and is pinned by `permissions_tests.rs:54` and `:67`, so U3 **extends its match arms** rather than writing a second formatter.
|
||||
- **Test scenarios:**
|
||||
- An error carrying a negative HRESULT formats exactly as `COM HRESULT 0x80070005 (E_ACCESSDENIED: Access is denied)`.
|
||||
- An error carrying a crate sentinel (`ERR_TIMEOUT`) maps to `TIMEOUT` and its message contains no `0x` HRESULT text.
|
||||
- An unrecognised sentinel maps to `INTERNAL`, not a guess.
|
||||
- `root_from_hwnd` on a destroyed HWND returns the `ErrorCode` U1 measured, and on a valid fixture window returns a walkable root (Windows-gated).
|
||||
- A classifier error message contains no app-derived content (KTD14) — asserted with a unique marker.
|
||||
- The non-Windows `imp` arm returns canned values so these tests run on any lane.
|
||||
- **Verification:** client construction and root resolution succeed on the Windows lane; no code path calls `UIAutomation::new()`, asserted by grep.
|
||||
|
||||
### U4. Build the fixture window, hosted out-of-process
|
||||
|
||||
- **Goal:** A window the test suite fully controls, walkable across a real process boundary, with no dependency on any installed application.
|
||||
- **Requirements:** R9.
|
||||
- **Dependencies:** U1, U3.
|
||||
- **Files:** `crates/windows/src/tree/fixture.rs`, `crates/windows/src/tree/fixture_tests.rs` (split window-creation from the pump if either nears 400 lines).
|
||||
- **Approach:** A `#[cfg(all(test, target_os = "windows"))]` harness that registers a window class and creates a top-level window with standard `BUTTON` / `EDIT` / `STATIC` children plus one `ES_PASSWORD` `EDIT` (KTD13's fixture), running a message pump on its own thread. Window creation uses `windows-sys` with the feature U2 adds.
|
||||
**Child-process mode is the default for walk tests:** the test binary re-executes itself with an env flag to host the fixture in a second process, so `ElementFromHandle` and the walk cross a real process boundary and exercise the failure taxonomy an in-process provider cannot produce (KTD8). The in-process variant is retained only for teardown and concurrency tests.
|
||||
Constraints that are correctness requirements: UIA calls run on a thread that owns no windows and is in the MTA (KTD9); the window is genuinely visible with a non-zero rect (`SW_SHOWNOACTIVATE`, off-screen, since the runner has one shared 1024x768 desktop); the pump runs for the duration of every UIA call. Ship a comctl32 v6 activation context so standard controls get full UIA support. Settle the class-name question explicitly — a per-fixture-unique class name, or one registration behind a `OnceLock` — because `RegisterClassExW` returns `ERROR_CLASS_ALREADY_EXISTS` when parallel tests re-register.
|
||||
- **Execution note:** Never block the window thread on the UIA thread's result; that is the documented deadlock. Prove the harness before building on it.
|
||||
- **Patterns to follow:** `crates/windows/src/system/com_runtime.rs` for MTA-thread assertions; `crates/windows/src/system/private_file/tests.rs` for scratch-resource teardown.
|
||||
- **Test scenarios:**
|
||||
- A child-process fixture is created, `root_from_hwnd` resolves it, and the walk finds the created child controls.
|
||||
- The fixture tears down with no leaked class, thread, or child process, and a second fixture in the same process succeeds.
|
||||
- Two fixtures created concurrently do not interfere.
|
||||
- The harness exposes no path that issues a UIA call from the window's own thread.
|
||||
- Assert the *rule* for a minimized window (top-level reports an empty rect while descendants report real extents, `IsOffscreen` false throughout) rather than this box's `-32000` literal, per A1-2.
|
||||
- **Verification:** fixture-backed tests pass on the Windows lane, stable across three consecutive runs; no coordinate literal is asserted; the child-process host is the one used by U6 and U7.
|
||||
|
||||
### U5. Read properties with an honest discriminator and a secure-field gate
|
||||
|
||||
- **Goal:** Property reads that distinguish "the provider says empty" from "the read failed", never leak secure content, and never leak content into errors.
|
||||
- **Requirements:** R5, R11, R12.
|
||||
- **Dependencies:** U3, U4.
|
||||
- **Files:** `crates/windows/src/tree/properties.rs`, `crates/windows/src/tree/property_ids.rs`, `crates/windows/src/tree/properties_tests.rs`.
|
||||
- **Approach:** Map an internal property set to `uiautomation::types::UIProperty` through an **exhaustive `match` with no catch-all arm** (KTD7). Build the discriminator UIA lacks: a not-supported sentinel compared by pointer identity, `VT_EMPTY` resolved conservatively, and a per-read outcome of `Known` / `Absent` / `Unknown` matching core's `LocatorField`. Bound every string read and mark truncation `Unknown` rather than presenting it as exact identity evidence.
|
||||
**The secure-field gate ships here (KTD13):** `UIA_IsPasswordPropertyId` is added to every cache request so it arrives in the same batch as the properties it gates, and every value-bearing property returns `Absent` when it is true — the Windows analogue of macOS's `safe_attribute_mask` + `should_read_value`.
|
||||
Populate the full `LocatorEvidence` slot set (R11): role and available-actions come from KTD10's seams, `identifiers` uses `IdentifierEvidence::typed` with `IdentifierKind::AutomationId` — `IdentifierEvidence::new` stamps `Unknown` and would silently void every ref downstream in `refs_validate.rs`.
|
||||
- **Execution note:** Write the Absent-versus-Unknown tests before the reader. This is the distinction that silently degrades everything downstream if collapsed, and a happy-path implementation cannot tell the two apart.
|
||||
- **Patterns to follow:** `crates/macos/src/tree/query/node_evidence.rs:40-46` (`option_field`) for the tri-state; `crates/macos/src/tree/node_attribute_status.rs:91-104` for absent-versus-unknown classification; `crates/macos/src/tree/node_attribute_names.rs:137-163` (`safe_attribute_mask`, `should_read_value`) for the secure-field guard; `crates/core/src/live_locator/locator_evidence.rs` for the target shape; `docs/solutions/conventions/keep-raw-arguments-out-of-trace-reachable-error-messages.md` for KTD14.
|
||||
- **Test scenarios:**
|
||||
- A property the provider does not implement yields `Absent`, not `Unknown`.
|
||||
- A property whose read fails yields `Unknown`, not `Absent`.
|
||||
- An empty-but-present string yields `Known("")`.
|
||||
- A string past the field bound yields `Unknown` and is not truncated into evidence.
|
||||
- Text typed into the fixture's `ES_PASSWORD` control never appears in any read outcome, for `Value`, `Name`, and `HelpText`.
|
||||
- A failed read against a control whose text is a unique marker produces an error whose message, details, and `platform_detail` contain no marker — mirroring `crates/core/src/context_scope_tests.rs:133`.
|
||||
- No literal property-id integer appears in the source, asserted by grep (A2-5).
|
||||
- **Verification:** the tri-state, secure-field, and redaction tests pass on the Windows lane; the id-literal grep passes; the mapping is exhaustive.
|
||||
|
||||
### U6. Walk the tree with a cycle guard and honest completeness
|
||||
|
||||
- **Goal:** A traversal that never silently truncates, never loops, bounds itself the way core expects, and emits what 2.4 can consume unchanged.
|
||||
- **Requirements:** R6, R11, R12.
|
||||
- **Dependencies:** U1, U4, U5.
|
||||
- **Files:** `crates/windows/src/tree/walker.rs`, `crates/windows/src/tree/walker_tests.rs` (this file carries the enumeration loop, cycle guard, two depth counters, completeness propagation, and a non-Windows twin — split it before it reaches 400 lines; the size gate runs on the macOS lane and the Windows dev box will not catch a breach).
|
||||
- **Approach:** Write the child-enumeration loop directly on `get_first_child` / `get_next_sibling`, classifying each `Err` **by the pair U1 measured** (KTD3): benign exhaustion ends the sibling list and leaves the subtree complete; a real failure marks the tree incomplete and surfaces a structured error obeying KTD14. Never call `UITreeWalker::get_children`.
|
||||
Guard cycles on an ancestor path keyed by runtime id, `compare_elements` as fallback (KTD4), inserting on entry and removing on **every** exit path. Keep raw and logical depth counters independent, with logical depth advancing through KTD10's `is_web_wrapper` seam so divergence is producible in 2.2 and fillable in 2.4.
|
||||
Emit `ObservedSubtree` (R11): `ObservedSubtree::new(evidence, children, subtree_complete, children_count)` with `.with_source_child_index(i)` recording the **native** child index — a walker that skips children must still record it or `RefEntry.scope.path` becomes unresolvable — and `.with_predecessors_complete(...)`. The root is assembled via `ObservedTree::from_roots(roots, ObservationSource::from_root(&root), LocatorStats::default(), structurally_complete)`. The walk never allocates refs, and its entry point takes an arbitrary root element (KTD12).
|
||||
- **Execution note:** Test the cycle guard against an in-memory fake enumerator that returns a repeated identity — a live window will not reliably reproduce identity reuse, so a real-window-only test would silently never enter the guard branch. Build the fakes **from U1's measured error pair**, not from a reading of the crate, or they only confirm the implementer's model.
|
||||
- **Patterns to follow:** `crates/macos/src/tree/query/traversal.rs:73-78`, `:82`, `:120`, `:157` (guard insert and all three removes); `:133` (logical-vs-raw depth); `:180-185` (raw-depth cap and incompleteness); `crates/macos/src/tree/query/arena.rs:6` and `:42-50` (ancestor set, handle-balance assertion).
|
||||
- **Test scenarios:**
|
||||
- A fake returning a repeated identity is skipped once, the skip is counted, and the walk terminates.
|
||||
- A fake returning benign exhaustion produces a **complete** subtree.
|
||||
- A fake returning a real failure produces an **incomplete** tree and a structured error, not a truncated success.
|
||||
- Raw-depth exhaustion marks incomplete and emits a child count instead of children.
|
||||
- Logical and raw depth diverge when `is_web_wrapper` is forced true by the fake.
|
||||
- Cycle-guard removal happens on the error exit path: a fake failing mid-subtree leaves the ancestor set empty.
|
||||
- A **live child-process** fixture walk terminates, finds the created controls, and **reports complete** — so an inverted discriminator fails here (Windows-gated).
|
||||
- The complete case produces a value `into_accessibility_tree()` accepts; the incomplete case one it refuses.
|
||||
- The walk never calls `SetFocus`, asserted by grep (A3-4).
|
||||
- **Verification:** fake-driven tests pass on any lane; the live cross-process walk passes on the Windows lane; the guard is observed skipping a cyclic fake; the emitted subtree round-trips through core's projection.
|
||||
|
||||
### U7. Batch reads only when the provider class makes it pay
|
||||
|
||||
- **Goal:** `CacheRequest` support whose correctness is asserted against a real cross-process provider and whose cost is not assumed.
|
||||
- **Requirements:** R7, R11.
|
||||
- **Dependencies:** U5, U6.
|
||||
- **Files:** `crates/windows/src/tree/cache.rs`, `crates/windows/src/tree/cache_tests.rs`.
|
||||
- **Approach:** Build with `create_cache_request()`, then `add_property` per property (every setter takes `&self`, not `&mut self`; there is no builder type). Always keep `ElementMode::Full` (KTD6). Include `TreeScope::Element` in whatever scope is chosen, since omitting it silently fails to cache the retrieved element's own properties; `TreeScope` has no bitwise operators, so a test settles whether `TreeScope::try_from(3)` succeeds and the code encodes the answer.
|
||||
Apply KTD5's policy: cache only properties that will be read, and skip caching when a root-level `ProviderDescription` read indicates an in-process client-side provider. **There is no node-count arm** — the count is unknown when the request is built. Add `UIA_IsPasswordPropertyId` to every request (KTD13). Reading a property absent from the request is an error, not a live fetch; classify it `Unknown`, never `Absent`.
|
||||
- **Execution note:** Assert cache correctness, never cache timing, and assert it against the **child-process** fixture — an in-process provider is exactly the class the policy says to skip, so validating there would validate the wrong branch.
|
||||
- **Patterns to follow:** `crates/macos/src/tree/node_attribute_names.rs:97-135` (demand-driven masks — request only what the evidence plan needs); `crates/macos/src/tree/node_attribute_fetch.rs:77-131` (per-slot outcome decoding).
|
||||
- **Test scenarios:**
|
||||
- Against the child-process fixture, a cached read returns the same value as an uncached read of the same property.
|
||||
- Reading a property not added to the request yields `Unknown` plus a structured error — never a silent live fetch and never `Absent`; the test records the HRESULT observed.
|
||||
- `ElementMode::Full` leaves live getters working after a cached read.
|
||||
- `TreeScope::try_from(3)` behaviour is asserted, and the scope the code uses includes `Element`.
|
||||
- The policy skips caching for an in-process `ProviderDescription` and engages for a cross-process one, asserted on the policy function with synthetic inputs.
|
||||
- No timing multiplier is asserted anywhere, checked by grep over this module.
|
||||
- **Verification:** correctness tests pass on the Windows lane against the child-process fixture; no timing assertion exists in the suite.
|
||||
|
||||
### U8. Produce the committed COM dumps
|
||||
|
||||
- **Goal:** The evidence §2.2 asks for, in the client stack this sub-phase actually ships.
|
||||
- **Requirements:** R8.
|
||||
- **Dependencies:** U6, U7.
|
||||
- **Files:** `crates/windows/examples/uia_tree_dump.rs`, `docs/plans/2026-07-27-002-captures/notepad-com.json`, `docs/plans/2026-07-27-002-captures/explorer-com.json`.
|
||||
- **Approach:** An example binary taking a window selector and a view, walking the tree and writing JSON as a **census** rather than a per-node dump: capture metadata, the shipped walker's own completeness verdict, one row per `ControlType` (node count, the `ClassName`s it appears under, how many carry an `AutomationId`, how many carry a `Name`), one row per distinct `ProviderDescription`, and one fully-detailed sample node per control type carrying `ClassName`, `AutomationId`, `Name` **presence only**, bounds and parent index.
|
||||
A full per-node dump was written first and discarded: measured on Explorer it was 2,117 lines carrying 23 control types, 40 class names and 15 provider strings, with 85 of the 100 provider strings verbatim repeats and every bounds value unassertable by rule (R9, KTD8 forbid asserting capture contents). It was 28% of the sub-phase's diff and write-only - nothing reads it, no test can check it, and it rots the day the target updates. The census preserves every claim the ledger makes at 1,135 lines for both targets. This settles the Open Question below about where the captures belong by removing most of the reason to ask. Run it on the dev box against classic Notepad and an Explorer folder window; commit both captures recording target variant, OS build, and client stack — the metadata whose absence made 2.0's managed dumps unusable as COM expectations.
|
||||
**Normalise host data before writing**, as 2.0's own captures already do (`probes/windows/captures/08-uia3-com/census.json` substitutes `pid:<pid>,providerId:<providerid>`): `Name` is presence-only by rule, and pids, provider ids, window handles, and user paths are substituted. **Gate every item behind `#[cfg(target_os = "windows")]` with a `#[cfg(not(...))] fn main()` stub** (KTD11) — `cargo check --all-targets` compiles examples, and an ungated reference to the target-gated `uiautomation` breaks the Linux gate. The tool is prerequisite-aware: an unresolvable target reports skipped, never a false green.
|
||||
- **Execution note:** For an Electron or Chromium target a first read understates the tree ~13x (A1-5, deterministic) — settle before capturing, and never capture behind another window (A1-6). Neither applies to Notepad or Explorer, but the tool should not encourage the mistake.
|
||||
- **Patterns to follow:** `crates/macos/examples/ax_probe.rs` for the fully-gated example shape; `crates/core/examples/locator_benchmark` for the example convention; `probes/windows/captures/08-uia3-com/census.json` for capture field shape and normalisation.
|
||||
- **Test scenarios:**
|
||||
- The example compiles under `cargo clippy --all-targets` on the Windows lane **and** under the Linux cross-check.
|
||||
- Running it against a non-existent window reports skipped with a structured reason and a non-zero exit, not a silent empty dump.
|
||||
- Each committed capture records target variant, OS build, and client stack.
|
||||
- The committed captures contain no raw decimal pid, no `hwnd:0x` literal, and no `C:\Users\` path — a rule assertion, not a content assertion, so it does not violate KTD8.
|
||||
- **Verification:** both captures committed with full metadata and normalisation; the example runs clean on the dev box; nothing in CI asserts capture contents.
|
||||
|
||||
### U9. Correct the five statements this sub-phase disproves
|
||||
|
||||
- **Goal:** `docs/phases.md` stops contradicting the measured evidence and itself.
|
||||
- **Requirements:** R10.
|
||||
- **Dependencies:** U1, U5, U6, U7.
|
||||
- **Files:** `docs/phases.md`.
|
||||
- **Approach:** Five in-place amendments — corrected, never annotated:
|
||||
1. **§2.2 scope and exit criteria (`:996`, `:1003`).** Replace the unconditional "with batched reads" with the conditional rule the API Mapping table at `:1106` already carries, citing A6-1 and A6-2. This reconciles an existing internal inconsistency rather than overturning a live criterion.
|
||||
2. **§2.2 cycle-guard rationale (`:995`).** Replace "mirrors macOS: reused pointers across sibling branches" with the Windows-true reason: UIA returns a new element proxy per query, so the guard keys on runtime id. The ancestor-path-not-global-set requirement stands.
|
||||
3. **§2.2 `new_direct()` reason (`:999`).** Replace "would re-initialise the apartment 2.1 already established" with KTD1's verified reasons: `new()` returns `S_FALSE` on an MTA thread and permanently leaks one init count in a long-lived process, and returns `Err(RPC_E_CHANGED_MODE)` on any STA host thread.
|
||||
4. **§2.4 exit criteria (`:1044`).** The current text — `snapshot --app Notepad -i`, "50+ refs" in VS Code, "≥100 refs with `--force-electron-a11y`" — is exactly the `app/provider` assertion class R9 and KTD8 forbid, against apps whose presence on `windows-latest` nothing establishes. Replace the fixed thresholds and named-app assertions with rule-shaped criteria (a resolvable root with a non-empty descendant set; depth-skip demonstrably reducing consumed depth budget on any wrapper-bearing target) plus an explicit skip-with-reason when the app is absent from the runner image. 2.4 is the sub-phase directly downstream of this one; leaving it means its planner writes tests R9 forbids or discovers mid-sub-phase that its gate is unreachable.
|
||||
5. **§2.12 — record, do not restate.** Append one `app/provider`-marked sentence recording the hosted runner's measured session state from U1 and citing Microsoft's contrary Azure Pipelines guidance. **Leave 2.12's self-hosted-runner justification intact** — the claim being corrected is true in its own context (a service-mode self-hosted runner), and rewriting it on evidence this plan's own Risks section calls third-party and one image old would weaken the recorded case for the only gate that would ever re-validate the walk against real apps on a representative shell.
|
||||
- **Patterns to follow:** the amendment style applied in commits `31ffd5f` and `4206c72`.
|
||||
- **Test scenarios:** `Test expectation: none -- documentation only.` Replacement verification: `src/cli/contract_tests.rs` `include_str!`s `.github/workflows/ci.yml`, not `phases.md`, so no test breaks; the review checks each amendment against its cited row.
|
||||
- **Verification:** each amended statement cites the evidence that disproved it; no annotation-style text is added.
|
||||
|
||||
---
|
||||
|
||||
## Verification Contract
|
||||
|
||||
| Gate | Command / check | Applies to |
|
||||
|---|---|---|
|
||||
| Repo gates (Windows dev box) | `cargo fmt --all -- --check`; `cargo clippy --locked -p agent-desktop-core -p agent-desktop-windows -p agent-desktop -p agent-desktop-ffi --all-targets -- -D warnings`; `cargo test --locked -p agent-desktop-core -p agent-desktop-windows --lib` | whole PR |
|
||||
| Cross-platform compile | `cargo check --locked -p agent-desktop-windows --all-targets --target x86_64-unknown-linux-gnu` — the only proof the non-Windows twins **and the example** compile | U2-U8 |
|
||||
| Core isolation | `cargo tree -p agent-desktop-core --edges normal,build,dev` on host and MSVC targets contains no platform or Win32 binding crate; the source-level gate still finds exactly two allowlisted `cfg(windows)` shims | U2 |
|
||||
| Probe branch taken | every U1 question is answered or its pre-committed branch is recorded as taken; no gate below rests on an unmeasured inference | U1 |
|
||||
| Fake-driven walk correctness | cycle skip, benign-exhaustion completeness, real-failure incompleteness, depth exhaustion, and forced depth divergence each asserted against an in-memory enumerator built from U1's measured error pair | U6 |
|
||||
| Cross-process live walk | the child-process fixture walk resolves a root, finds the created controls, and **reports complete**, stable across three consecutive runs | U4, U6 |
|
||||
| Output shape | the complete case produces a value core's `into_accessibility_tree()` accepts; the incomplete case one it refuses | U5, U6 |
|
||||
| Cache correctness | against the child-process fixture: cached equals uncached; an uncached property yields `Unknown` plus a structured error; `TreeScope::try_from(3)` behaviour asserted; no timing assertion exists | U7 |
|
||||
| Secure content | text in the fixture's `ES_PASSWORD` control never appears in any read outcome for `Value`, `Name`, or `HelpText` | U5 |
|
||||
| Error redaction | a failed read against a marker-named control produces an error whose message, details, and `platform_detail` contain no marker | U3, U5, U6, U7 |
|
||||
| Evidence honesty | no test asserts a node count, tree shape, timing multiplier, coordinate literal, or any `app/provider` fact | U4-U8 |
|
||||
| No banned calls | no `UIAutomation::new()`, no `UITreeWalker::get_children`, no `SetFocus`, no literal UIA property-id integer — each asserted by grep | U3, U5, U6 |
|
||||
| Size | Windows release binary under 15 MiB with `uiautomation` linked | U2 |
|
||||
| Dev-box evidence | both COM censuses committed with variant, build, client stack, host-data normalisation, the walker's completeness verdict, and non-empty control-type, provider and sample sections | U8 |
|
||||
| Doc truth | each of the five `docs/phases.md` amendments cites the row or source that disproved the prior statement | U9 |
|
||||
| PR is green | every required check on a PR into `feat/windows-adapter`, never `main` | whole PR |
|
||||
|
||||
**Pre-commit note.** `.githooks/pre-commit` runs unqualified `cargo clippy --all-targets` and `cargo test --lib --workspace`, both of which resolve through `default-members` and fail on a Windows dev box. Commit with `SKIP_PRECOMMIT=1` and run the package-scoped forms above instead.
|
||||
|
||||
**File-size note.** `scripts/check-rust-file-size.sh` runs on the **macOS** lane over every repo `.rs` file at a 400-line cap, plus `check_rust_comments.py`. A Windows-only local check will not catch a violation; `walker.rs` is the likeliest breach and should be split proactively.
|
||||
|
||||
**Workflow-coupling note.** `src/cli/contract_tests.rs` `include_str!`s `ci.yml` and asserts exact substrings from the Windows lane. U1 adds a *separate* workflow file and does not touch `ci.yml`; any incidental edit to the Windows lane during this sub-phase must be followed by `cargo test -p agent-desktop`.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- A PR from `feat/windows-2.2-uia-tree-walk` into `feat/windows-adapter` is open and green.
|
||||
- U1 ran on this PR without touching `main`, its rows are committed, and every question it could not answer has its pre-committed branch recorded as taken.
|
||||
- `UIAElement` encapsulates its element with no `Copy`, no raw accessor, no hand-written `Clone`/`Drop`, and no `unsafe impl Send` anywhere in the sub-phase.
|
||||
- The client is constructed by direct `CoCreateInstance` on `CUIAutomation8` with `ConnectionTimeout` set, `UIAutomation::new()` is never called and no unbounded fallback exists, a production `root_from_hwnd` encodes U1's measured HRESULT mapping, and errors branch on `result()` so a crate sentinel never prints a fabricated HRESULT.
|
||||
- The walk owns its enumeration loop, classifies exhaustion from failure using the measured pair, guards cycles on an ancestor path keyed by runtime id, diverges logical from raw depth through the `is_web_wrapper` seam, and reports complete on a live cross-process walk.
|
||||
- The walk's output is constructible into `ObservedSubtree`/`ObservedTree` and round-trips through core's projection.
|
||||
- Property reads distinguish `Known`, `Absent`, and `Unknown`; no value-bearing property is read from a password field; no UIA property id appears as a literal.
|
||||
- `CacheRequest` correctness is asserted against a cross-process provider, timing is not, `ElementMode::Full` is never weakened, and no node-count arm exists.
|
||||
- No error raised anywhere in this sub-phase carries app-derived content.
|
||||
- Committed COM censuses of Notepad and Explorer exist as dev-box evidence, normalised, recording variant, build, client stack and the walker's own completeness verdict, with per-control-type and per-provider rows and one sample node per control type rather than a full per-node dump.
|
||||
- `ObservationOps::observe_tree` still returns `PLATFORM_NOT_SUPPORTED`; no ref is allocated anywhere in `crates/windows`.
|
||||
- The five `docs/phases.md` statements are corrected in place with their disproving evidence cited.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **The runner-environment evidence is third-party and one image old.** The interactive-session conclusion rests on one public workflow's observations and one WinAppDriver issue, both captured on **windows-2022**, not the current Server 2025 image; Microsoft's Azure Pipelines guidance states the opposite for *its* hosted agents (scoped to Selenium interactability and a different agent deployment, but a genuine contrary vendor source). U1 replaces this inference with measurement and pre-commits the branch if it fails. Note KTD8's three blockers do **not** depend on this inference.
|
||||
- **The walk reads across an integrity boundary.** A9-2 measured that from Medium against a High-integrity target, name, ClassName, ControlType, bounds, and node count return byte-identical — so walked content can originate in elevated UI. 2.2 deliberately applies no integrity gate, because A9-2 settles observation as available and not to be refused; the UIPI gate belongs to 2.6's input path.
|
||||
- **`^0.62.2` is a caret range, not a pin.** `uiautomation` 0.25.0 requires `windows ^0.62.2` and `windows-core ^0.62.2`; it resolves to 0.62.2 only because nothing newer exists in range. `--locked` contains the blast radius, but the recorded "pin" at `docs/phases.md:1323` is looser than it reads — which is why U3's and U7's HRESULT facts are tests, not committed JSON: a test fails loudly when the dependency moves.
|
||||
- **UIA's hang guard is weaker than the macOS one.** macOS slices every IPC with `AXUIElementSetMessagingTimeout` at 250 ms. UIA has no per-element equivalent; `ConnectionTimeout` (2 s) and `TransactionTimeout` (20 s) are not documented to bound the `WM_GETOBJECT` `SendMessage` that `ElementFromHandle` issues. Whether a non-pumping target produces a clean timeout or a hang is unverified, and the fixture cannot produce the condition.
|
||||
- **A UIA client can permanently bind the wrong provider.** A1-7 measured that reading a WPF window before its automation peer exists binds the generic HWND provider *permanently* — zero children, and a 30 s poll including a forced `FindAll` never recovered it; recovery needed a new HWND or a new client.
|
||||
- **A tree walk is not exhaustive over the HWND set.** A5-2 measured a zero-size control reachable by `GetDlgItem` and `ElementFromHandle`, visible, and returned by *no* walk on either stack.
|
||||
- **An `ElementFromHandle` root can span processes.** A1-3 measured Settings resolving as an `ApplicationFrameWindow` in one pid containing a `CoreWindow` in another, with a 49-node walk spanning both. Any pid-based scoping breaks on UWP.
|
||||
- **The fixture is a new dependency on runner desktop behaviour.** It needs a visible, non-zero-rect window plus a child process on a shared 1024x768 desktop with parallel tests running. U1 measures viability first and U4 depends on it; U6's correctness branches stay fake-driven so a fixture outage degrades coverage rather than blocking the sub-phase.
|
||||
- **Five scope items had zero prior evidence.** Refcount behaviour, cycle occurrence, cache-miss semantics, cross-apartment element use, and mid-walk process death were unmeasurable or unmeasured in 2.0. U1 closes two; U3/U7 close two as tests; cross-apartment use is avoided by construction rather than characterised. Real-platform confirmation of cycle occurrence will still not exist after this PR — the guard is cheap insurance against a condition nobody has observed on Windows.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **At what node count does `CacheRequest` stop being a pessimization against an out-of-process provider?** A6-1 and A6-2 bracket it between 3 and 220 with nothing measured between, and both are managed-stack or single-target. KTD5 deliberately ships no threshold; the crossover wants a dev-box measurement row before any node-count arm is reintroduced.
|
||||
- **Does the repo-wide "Definition of Done: Performance Baseline" apply to this sub-phase?** The Verification Contract bans timing assertions in CI while `scripts/perf-baseline-compare.sh` is a macOS/AX harness that cannot run here, leaving conditional batching with no calibration path in-repo. Needs a one-line ruling for every remaining Windows sub-phase, not just this one.
|
||||
- **Do committed evidence artifacts count against the Platform Delivery Model's 2,000-changed-line cap?** 2.0 landed 21,048 insertions and 2.1 landed 4,921 against a ~1.3k estimate, which implies exclusion in practice, but no rule states it and the origin's exclusion list (Cargo.lock, generated FFI header, vendored fixtures) does not obviously cover captures.
|
||||
- **Does the walk cross a UWP process boundary transparently?** A1-3 records the shape; nothing measures what fails when the hosted process dies mid-walk. Deferred to 2.4, which owns UWP window identity.
|
||||
- **Should U8's captures live under `probes/windows/captures/` rather than beside this plan?** The cross-cutting DoD says probe evidence is committed alongside the sub-phase plan, but 2.0's normalisation tooling lives in the probe tree. Deferred; U8 applies the normalisation rule either way.
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- `docs/phases.md` §2.2 (`:988-1006`), §2.4 (`:1026-1044`), Windows API Mapping (`:1101-1130`), Windows Engineering Invariants (`:826-838`), cross-cutting DoD (`:942-952`), recorded pins (`:1314-1344`).
|
||||
- `probes/windows/FINDINGS.md` rows A1-1, A1-2, A1-3, A1-5, A1-6, A1-7, A2-1, A2-2, A2-3, A2-4, A2-5, A3-4, A5-2, A6-1, A6-2, A7-1, A7-2, A7-3, A7-4, A8-4, A9-2, A10-1, A10-2, A10-7, C-9, C-11, and the ledger's own KTD7 scope rule.
|
||||
- `probes/windows/captures/08-uia3-com/{census,walker,cache-timing,ids}.json`; `captures/01-tree-dump/{notepad,explorer,settings,summary}.json`.
|
||||
- macOS reference: `crates/macos/src/tree/ax_element.rs:7-30`; `query/traversal.rs:62-78`, `:82`, `:120`, `:133`, `:157`, `:180-185`; `query/arena.rs:6`, `:42-50`; `query/node_evidence.rs:40-46`; `query/node_read.rs:89-94`, `:142-150`; `node_attribute_names.rs:97-135`, `:137-163`; `node_attribute_fetch.rs:77-131`; `node_attribute_status.rs:91-104`; `bounded_string.rs:10-46`; `crates/macos/src/adapter.rs:15-31`; `crates/macos/examples/ax_probe.rs`.
|
||||
- Core contracts: `crates/core/src/adapter/observation.rs:29-161`; `live_locator/{observed_tree,observed_subtree,locator_evidence,observation_request,observation_budget}.rs`; `node.rs`; `role.rs`; `roles.rs:6-25`; `ref_alloc.rs:66-78`; `refs_validate.rs`; `ref_action.rs:238`, `:289`; `context_scope_tests.rs:133`.
|
||||
- Sub-phase 2.1 as merged (`18daaa8`, `00a4282`): `crates/windows/src/system/com_runtime.rs`, `session.rs`, `permissions.rs:133-139`; `src/main.rs:196`; `crates/ffi/src/adapter.rs:117`.
|
||||
- `docs/solutions/best-practices/never-ship-platform-code-that-ci-cannot-execute.md`; `real-app-tests-are-the-platform-adapter-gate.md`; `deduplicate-ref-allocator-via-config-struct-2026-04-14.md`; `logic-errors/progressive-snapshot-review-contract-2026-04-16.md`; `conventions/keep-raw-arguments-out-of-trace-reachable-error-messages.md`.
|
||||
- `uiautomation` 0.25.0 verified against docs.rs and `docs.rs/uiautomation/0.25.0/src/uiautomation/core.rs.html` (constructors, `Handle`, `UITreeWalker`, `UICacheRequest`, `Error`); crates.io metadata for the `windows ^0.62.2` requirement. **The crate's GitHub `_autodocs/` directory documents a cache API that does not exist (`tree_scope(&mut self, …)` versus the real `set_tree_scope(&self, …)`) — do not copy from it.**
|
||||
- Microsoft Learn: [UI Automation Threading Issues](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-threading), [Caching for Clients](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-cachingforclients), [Handling WM_GETOBJECT](https://learn.microsoft.com/en-us/windows/win32/winauto/handling-the-wm-getobject-message), [GetRuntimeId](https://learn.microsoft.com/en-us/windows/win32/api/uiautomationclient/nf-uiautomationclient-iuiautomationelement-getruntimeid), [UIA Support for Standard Controls](https://learn.microsoft.com/en-us/dotnet/framework/ui-automation/ui-automation-support-for-standard-controls), [Configure for UI testing](https://learn.microsoft.com/en-us/azure/devops/pipelines/test/ui-testing-considerations) (the contrary Azure Pipelines source).
|
||||
- GitHub Actions: [Events that trigger workflows — `workflow_dispatch`](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows) (default-branch requirement); [actions/runner-images Windows2025-Readme](https://github.com/actions/runner-images); [maruel/query-github-runner](https://github.com/maruel/query-github-runner); [microsoft/WinAppDriver#1789](https://github.com/microsoft/WinAppDriver/issues/1789); [microsoft/winappCli ui-automation docs](https://github.com/microsoft/winappCli/blob/main/docs/ui-automation.md).
|
||||
18
probes/windows/14-ci-capability/captures/session-ci.json
Normal file
18
probes/windows/14-ci-capability/captures/session-ci.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"probe": "14-ci-capability",
|
||||
"label": "ci",
|
||||
"scope": "app/provider",
|
||||
"stack": "n/a",
|
||||
"runnerImageOs": "win25-vs2026",
|
||||
"runnerImageVersion": "20260714.173.1",
|
||||
"osCaption": "Microsoft Windows Server 2025 Datacenter",
|
||||
"osVersion": "10.0.26100",
|
||||
"osBuildNumber": "26100",
|
||||
"osInstallationType": "Server",
|
||||
"sessionName": null,
|
||||
"sessionId": 2,
|
||||
"userInteractive": true,
|
||||
"windowStation": "WinSta0",
|
||||
"desktop": "Default",
|
||||
"queryUserSessions": "SESSIONNAME USERNAME ID STATE TYPE DEVICE \r\n services 0 Disc \r\n\u003econsole <user> 2 Active \r\n 31c5ce94259d4006a9e4 65536 Listen \r\n rdp-tcp 65537 Listen"
|
||||
}
|
||||
18
probes/windows/14-ci-capability/captures/session-devbox.json
Normal file
18
probes/windows/14-ci-capability/captures/session-devbox.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"probe": "14-ci-capability",
|
||||
"label": "devbox",
|
||||
"scope": "app/provider",
|
||||
"stack": "n/a",
|
||||
"runnerImageOs": "\u003cnot a hosted runner\u003e",
|
||||
"runnerImageVersion": "\u003cnot a hosted runner\u003e",
|
||||
"osCaption": "Microsoft Windows Server 2019 Datacenter",
|
||||
"osVersion": "10.0.17763",
|
||||
"osBuildNumber": "17763",
|
||||
"osInstallationType": "Server",
|
||||
"sessionName": "Console",
|
||||
"sessionId": 1,
|
||||
"userInteractive": true,
|
||||
"windowStation": "WinSta0",
|
||||
"desktop": "Default",
|
||||
"queryUserSessions": "SESSIONNAME USERNAME ID STATE TYPE DEVICE \r\n services 0 Disc \r\n\u003econsole <user> 1 Active"
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
{
|
||||
"measurements": {
|
||||
"child_process": {
|
||||
"descendants_found": 10,
|
||||
"direct_children_found": 5,
|
||||
"hosted": true,
|
||||
"root_resolved": true
|
||||
},
|
||||
"exhaustion": {
|
||||
"code": 0,
|
||||
"result_hex": null,
|
||||
"result_is_none": true
|
||||
},
|
||||
"forced_failure": {
|
||||
"element_from_handle": {
|
||||
"code": -2147220991,
|
||||
"result_hex": "0x80040201",
|
||||
"result_is_none": false
|
||||
},
|
||||
"get_first_child": {
|
||||
"code": -2147467259,
|
||||
"result_hex": "0x80004005",
|
||||
"result_is_none": false
|
||||
},
|
||||
"get_next_sibling": {
|
||||
"code": 0,
|
||||
"result_hex": null,
|
||||
"result_is_none": true
|
||||
}
|
||||
},
|
||||
"secure_field": {
|
||||
"help_text": {
|
||||
"contains_marker": false,
|
||||
"is_null": false,
|
||||
"length": 0,
|
||||
"variant_type": "VARENUM(8)"
|
||||
},
|
||||
"legacy_value": {
|
||||
"contains_marker": false,
|
||||
"is_null": false,
|
||||
"length": 0,
|
||||
"variant_type": "VARENUM(8)"
|
||||
},
|
||||
"name": {
|
||||
"contains_marker": false,
|
||||
"is_null": false,
|
||||
"length": 12,
|
||||
"variant_type": "VARENUM(8)"
|
||||
},
|
||||
"secure_field_found": true,
|
||||
"value": {
|
||||
"contains_marker": false,
|
||||
"is_null": false,
|
||||
"length": 0,
|
||||
"variant_type": "VARENUM(8)"
|
||||
}
|
||||
},
|
||||
"self_created_window": {
|
||||
"created": true,
|
||||
"descendants_found": 10,
|
||||
"non_zero_rect": true,
|
||||
"root_failure": null,
|
||||
"root_resolved": true,
|
||||
"visible": true
|
||||
}
|
||||
},
|
||||
"probe": "14-ci-capability",
|
||||
"stack": "uia3-com",
|
||||
"uiautomation_version": "0.25.0"
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
{
|
||||
"measurements": {
|
||||
"child_process": {
|
||||
"descendants_found": 10,
|
||||
"direct_children_found": 5,
|
||||
"hosted": true,
|
||||
"root_resolved": true
|
||||
},
|
||||
"exhaustion": {
|
||||
"code": 0,
|
||||
"result_hex": null,
|
||||
"result_is_none": true
|
||||
},
|
||||
"forced_failure": {
|
||||
"element_from_handle": {
|
||||
"code": -2147220991,
|
||||
"result_hex": "0x80040201",
|
||||
"result_is_none": false
|
||||
},
|
||||
"get_first_child": {
|
||||
"code": -2147467259,
|
||||
"result_hex": "0x80004005",
|
||||
"result_is_none": false
|
||||
},
|
||||
"get_next_sibling": {
|
||||
"code": 0,
|
||||
"result_hex": null,
|
||||
"result_is_none": true
|
||||
}
|
||||
},
|
||||
"secure_field": {
|
||||
"help_text": {
|
||||
"contains_marker": false,
|
||||
"is_null": false,
|
||||
"length": 0,
|
||||
"variant_type": "VARENUM(8)"
|
||||
},
|
||||
"legacy_value": {
|
||||
"contains_marker": false,
|
||||
"is_null": false,
|
||||
"length": 0,
|
||||
"variant_type": "VARENUM(8)"
|
||||
},
|
||||
"name": {
|
||||
"contains_marker": false,
|
||||
"is_null": false,
|
||||
"length": 12,
|
||||
"variant_type": "VARENUM(8)"
|
||||
},
|
||||
"secure_field_found": true,
|
||||
"value": {
|
||||
"contains_marker": false,
|
||||
"is_null": false,
|
||||
"length": 0,
|
||||
"variant_type": "VARENUM(8)"
|
||||
}
|
||||
},
|
||||
"self_created_window": {
|
||||
"created": true,
|
||||
"descendants_found": 10,
|
||||
"non_zero_rect": true,
|
||||
"root_failure": null,
|
||||
"root_resolved": true,
|
||||
"visible": true
|
||||
}
|
||||
},
|
||||
"probe": "14-ci-capability",
|
||||
"stack": "uia3-com",
|
||||
"uiautomation_version": "0.25.0"
|
||||
}
|
||||
183
probes/windows/14-ci-capability/probe.ps1
Normal file
183
probes/windows/14-ci-capability/probe.ps1
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Sub-phase 2.2 CI capability probe.
|
||||
|
||||
.DESCRIPTION
|
||||
Measures the runner-environment facts the 2.2 plan refuses to infer - the
|
||||
session, window station and interactivity a UI Automation client needs -
|
||||
and drives probe.rs, which measures the real uiautomation crate's
|
||||
end-of-list discriminator, cross-process walkability and secure-field
|
||||
behaviour.
|
||||
|
||||
Captures are written beside this script under captures\, BOM-less UTF-8,
|
||||
through the corpus redaction gate in ..\common.ps1.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$SkipRustProbe,
|
||||
[ValidateSet('devbox', 'ci')][string]$Label = 'devbox',
|
||||
[string]$UiAutomationVersion = '0.25.0'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version 2.0
|
||||
|
||||
. (Join-Path (Split-Path -Parent $PSCommandPath) '..\common.ps1')
|
||||
Initialize-ProbeRedaction
|
||||
|
||||
$script:ProbeDir = Split-Path -Parent $PSCommandPath
|
||||
$script:CaptureDir = Join-Path $script:ProbeDir 'captures'
|
||||
if (-not (Test-Path -LiteralPath $script:CaptureDir)) {
|
||||
New-Item -ItemType Directory -Path $script:CaptureDir -Force | Out-Null
|
||||
}
|
||||
|
||||
function Write-CapabilityCapture {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Name,
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content
|
||||
)
|
||||
$redacted = Protect-ProbeText -Text $Content
|
||||
$path = Join-Path $script:CaptureDir $Name
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
[IO.File]::WriteAllText($path, $redacted, $utf8NoBom)
|
||||
if (-not (Test-CaptureRedaction -Path $path)) {
|
||||
throw "redaction residue in $path"
|
||||
}
|
||||
return $path
|
||||
}
|
||||
|
||||
function Initialize-CapabilityNative {
|
||||
if ('AgentDesktopCapability.Native' -as [type]) { return }
|
||||
$source = @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AgentDesktopCapability {
|
||||
public static class Native {
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern IntPtr GetProcessWindowStation();
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern IntPtr GetThreadDesktop(uint dwThreadId);
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool GetUserObjectInformationW(IntPtr hObj, int nIndex, StringBuilder pvInfo, int nLength, out int lpnLengthNeeded);
|
||||
|
||||
public static string ObjectName(IntPtr handle) {
|
||||
if (handle == IntPtr.Zero) { return "<null>"; }
|
||||
StringBuilder buffer = new StringBuilder(256);
|
||||
int needed = 0;
|
||||
if (!GetUserObjectInformationW(handle, 2, buffer, buffer.Capacity, out needed)) {
|
||||
return "<error " + Marshal.GetLastWin32Error() + ">";
|
||||
}
|
||||
return buffer.ToString();
|
||||
}
|
||||
|
||||
public static string WindowStationName() { return ObjectName(GetProcessWindowStation()); }
|
||||
public static string DesktopName() { return ObjectName(GetThreadDesktop(GetCurrentThreadId())); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
Add-Type -TypeDefinition $source -Language CSharp | Out-Null
|
||||
}
|
||||
|
||||
function Get-QuerySessionOutput {
|
||||
try {
|
||||
$output = & qwinsta.exe 2>&1 | Out-String
|
||||
return $output.Trim()
|
||||
} catch {
|
||||
return "<qwinsta unavailable: $($_.Exception.Message)>"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SessionEvidence {
|
||||
Initialize-CapabilityNative
|
||||
$os = Get-CimInstance -ClassName Win32_OperatingSystem
|
||||
return [ordered]@{
|
||||
probe = '14-ci-capability'
|
||||
label = $Label
|
||||
scope = 'app/provider'
|
||||
stack = 'n/a'
|
||||
runnerImageOs = if ($env:ImageOS) { $env:ImageOS } else { '<not a hosted runner>' }
|
||||
runnerImageVersion = if ($env:ImageVersion) { $env:ImageVersion } else { '<not a hosted runner>' }
|
||||
osCaption = $os.Caption
|
||||
osVersion = $os.Version
|
||||
osBuildNumber = $os.BuildNumber
|
||||
osInstallationType = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').InstallationType
|
||||
sessionName = $env:SESSIONNAME
|
||||
sessionId = (Get-Process -Id $PID).SessionId
|
||||
userInteractive = [Environment]::UserInteractive
|
||||
windowStation = [AgentDesktopCapability.Native]::WindowStationName()
|
||||
desktop = [AgentDesktopCapability.Native]::DesktopName()
|
||||
queryUserSessions = Get-QuerySessionOutput
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-RustProbe {
|
||||
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
|
||||
if (-not $cargo) {
|
||||
return [ordered]@{ skipped = 'cargo is not installed on this machine' }
|
||||
}
|
||||
$work = Join-Path ([IO.Path]::GetTempPath()) ('agent-desktop-capability-' + [guid]::NewGuid())
|
||||
New-Item -ItemType Directory -Path (Join-Path $work 'src') -Force | Out-Null
|
||||
try {
|
||||
$manifest = @"
|
||||
[package]
|
||||
name = "agent-desktop-capability-probe"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
uiautomation = "=$UiAutomationVersion"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[workspace]
|
||||
"@
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
[IO.File]::WriteAllText((Join-Path $work 'Cargo.toml'), $manifest, $utf8NoBom)
|
||||
Copy-Item -LiteralPath (Join-Path $script:ProbeDir 'probe.rs') -Destination (Join-Path $work 'src\main.rs') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $script:ProbeDir 'probe_window.rs') -Destination (Join-Path $work 'src\probe_window.rs') -Force
|
||||
$env:PROBE_UIAUTOMATION_VERSION = $UiAutomationVersion
|
||||
Push-Location $work
|
||||
try {
|
||||
& cargo build --quiet 2>&1 | Write-Verbose
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return [ordered]@{ skipped = "cargo build failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
$raw = & cargo run --quiet 2>$null | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return [ordered]@{ skipped = "the probe exited with code $LASTEXITCODE" }
|
||||
}
|
||||
return ($raw | ConvertFrom-Json)
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$session = Get-SessionEvidence
|
||||
$sessionPath = Write-CapabilityCapture -Name "session-$Label.json" -Content (ConvertTo-Json -InputObject $session -Depth 10)
|
||||
Write-Host "wrote $sessionPath"
|
||||
|
||||
if ($SkipRustProbe) {
|
||||
Write-Host 'skipping the Rust probe by request'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$capability = Invoke-RustProbe
|
||||
$capabilityPath = Write-CapabilityCapture -Name "uia-capability-$Label.json" -Content (ConvertTo-Json -InputObject $capability -Depth 20)
|
||||
Write-Host "wrote $capabilityPath"
|
||||
exit 0
|
||||
270
probes/windows/14-ci-capability/probe.rs
Normal file
270
probes/windows/14-ci-capability/probe.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//! Sub-phase 2.2 CI capability probe.
|
||||
//!
|
||||
//! Measures the four facts the 2.2 plan refuses to infer: whether a window the
|
||||
//! probe process creates itself is visible, non-zero-rect and UI Automation
|
||||
//! walkable from a second MTA thread; the exact `code()`/`result()` pair the
|
||||
//! real `uiautomation` crate returns at sibling exhaustion and at a forced
|
||||
//! enumeration failure; and whether a password control leaks its content
|
||||
//! through `Value`, `Name` or `HelpText`.
|
||||
//!
|
||||
//! Writes one JSON document to stdout. Nothing here is product code.
|
||||
|
||||
use std::env;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use uiautomation::types::{Handle, UIProperty};
|
||||
use uiautomation::{Error as UiaError, UIAutomation, UIElement, UITreeWalker};
|
||||
|
||||
const HOST_FLAG: &str = "--host";
|
||||
const SECRET_MARKER: &str = "zzprobesecretzz";
|
||||
const HOST_HANDLE_PREFIX: &str = "HWND=";
|
||||
const WALK_DEPTH_LIMIT: u32 = 12;
|
||||
const HOST_READY_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "probe_window.rs"]
|
||||
mod win;
|
||||
|
||||
fn failure_shape(error: &UiaError) -> Value {
|
||||
json!({
|
||||
"code": error.code(),
|
||||
"result_is_none": error.result().is_none(),
|
||||
"result_hex": error.result().map(|hresult| format!("0x{:08X}", hresult.0 as u32)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enumerates children the way sub-phase 2.2's walker will, so the terminating
|
||||
/// error is observed rather than swallowed by `UITreeWalker::get_children`.
|
||||
fn enumerate_children(
|
||||
walker: &UITreeWalker,
|
||||
parent: &UIElement,
|
||||
) -> (Vec<UIElement>, Option<UiaError>) {
|
||||
let mut children = Vec::new();
|
||||
let mut current = match walker.get_first_child(parent) {
|
||||
Ok(first) => first,
|
||||
Err(error) => return (children, Some(error)),
|
||||
};
|
||||
loop {
|
||||
let next = walker.get_next_sibling(¤t);
|
||||
children.push(current);
|
||||
match next {
|
||||
Ok(sibling) => current = sibling,
|
||||
Err(error) => return (children, Some(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn count_descendants(walker: &UITreeWalker, root: &UIElement, depth: u32) -> u32 {
|
||||
if depth >= WALK_DEPTH_LIMIT {
|
||||
return 0;
|
||||
}
|
||||
let (children, _) = enumerate_children(walker, root);
|
||||
children.iter().fold(children.len() as u32, |total, child| {
|
||||
total + count_descendants(walker, child, depth + 1)
|
||||
})
|
||||
}
|
||||
|
||||
fn last_descendant(walker: &UITreeWalker, root: &UIElement) -> UIElement {
|
||||
let (children, _) = enumerate_children(walker, root);
|
||||
match children.into_iter().next_back() {
|
||||
Some(child) => last_descendant(walker, &child),
|
||||
None => root.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_property(element: &UIElement, property: UIProperty) -> Value {
|
||||
match element.get_property_value(property) {
|
||||
Ok(value) => {
|
||||
let rendered = value.get_string().unwrap_or_default();
|
||||
json!({
|
||||
"variant_type": format!("{:?}", value.get_type()),
|
||||
"is_null": value.is_null(),
|
||||
"length": rendered.chars().count(),
|
||||
"contains_marker": rendered.contains(SECRET_MARKER),
|
||||
})
|
||||
}
|
||||
Err(error) => json!({ "failed": failure_shape(&error) }),
|
||||
}
|
||||
}
|
||||
|
||||
fn password_leak_report(walker: &UITreeWalker, root: &UIElement) -> Value {
|
||||
let (children, _) = enumerate_children(walker, root);
|
||||
let secure = children.iter().find(|child| {
|
||||
child
|
||||
.get_property_value(UIProperty::IsPassword)
|
||||
.ok()
|
||||
.and_then(|value| value.get_value().ok())
|
||||
.is_some_and(|value| matches!(value, uiautomation::variants::Value::BOOL(true)))
|
||||
});
|
||||
match secure {
|
||||
None => json!({ "secure_field_found": false }),
|
||||
Some(element) => json!({
|
||||
"secure_field_found": true,
|
||||
"value": read_property(element, UIProperty::ValueValue),
|
||||
"legacy_value": read_property(element, UIProperty::LegacyIAccessibleValue),
|
||||
"name": read_property(element, UIProperty::Name),
|
||||
"help_text": read_property(element, UIProperty::HelpText),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn spawn_host() -> Result<(std::process::Child, isize), String> {
|
||||
let executable = env::current_exe().map_err(|error| error.to_string())?;
|
||||
let mut host = Command::new(executable)
|
||||
.arg(HOST_FLAG)
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let stdout = host.stdout.take().ok_or("host stdout unavailable")?;
|
||||
let (sender, receiver) = channel();
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
if let Some(handle) = line.strip_prefix(HOST_HANDLE_PREFIX) {
|
||||
let _ = sender.send(handle.trim().parse::<isize>().unwrap_or(0));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = sender.send(0);
|
||||
});
|
||||
match receiver.recv_timeout(HOST_READY_TIMEOUT) {
|
||||
Ok(handle) if handle != 0 => Ok((host, handle)),
|
||||
_ => {
|
||||
let _ = host.kill();
|
||||
Err("the host process never reported a window handle".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn in_process_window(sender: Sender<Result<isize, String>>) {
|
||||
thread::spawn(move || win::host_window("AgentDesktopProbeLocal", SECRET_MARKER, sender));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn measure() -> Value {
|
||||
let apartment = win::join_multithreaded_apartment();
|
||||
let automation = match UIAutomation::new_direct() {
|
||||
Ok(automation) => automation,
|
||||
Err(error) => {
|
||||
return json!({
|
||||
"co_initialize_hresult": format!("0x{apartment:08X}"),
|
||||
"client": { "failed": failure_shape(&error) },
|
||||
});
|
||||
}
|
||||
};
|
||||
let walker = match automation.get_raw_view_walker() {
|
||||
Ok(walker) => walker,
|
||||
Err(error) => return json!({ "walker": { "failed": failure_shape(&error) } }),
|
||||
};
|
||||
|
||||
let (sender, receiver) = channel();
|
||||
in_process_window(sender);
|
||||
let local_handle = receiver
|
||||
.recv_timeout(HOST_READY_TIMEOUT)
|
||||
.unwrap_or_else(|_| Err("the in-process window never became ready".into()));
|
||||
let local = match local_handle {
|
||||
Ok(handle) => {
|
||||
let geometry = win::geometry(handle);
|
||||
let root = automation.element_from_handle(Handle::from(handle));
|
||||
json!({
|
||||
"created": true,
|
||||
"visible": geometry.visible,
|
||||
"non_zero_rect": geometry.width > 0 && geometry.height > 0,
|
||||
"root_resolved": root.is_ok(),
|
||||
"descendants_found": root
|
||||
.as_ref()
|
||||
.map(|element| count_descendants(&walker, element, 0))
|
||||
.unwrap_or(0),
|
||||
"root_failure": root.err().as_ref().map(failure_shape),
|
||||
})
|
||||
}
|
||||
Err(error) => json!({ "created": false, "error": error }),
|
||||
};
|
||||
|
||||
let (mut host, host_handle) = match spawn_host() {
|
||||
Ok(host) => host,
|
||||
Err(error) => {
|
||||
return json!({ "self_created_window": local, "child_process": { "hosted": false, "error": error } });
|
||||
}
|
||||
};
|
||||
let root = match automation.element_from_handle(Handle::from(host_handle)) {
|
||||
Ok(root) => root,
|
||||
Err(error) => {
|
||||
let _ = host.kill();
|
||||
return json!({
|
||||
"self_created_window": local,
|
||||
"child_process": { "hosted": true, "root_resolved": false, "root_failure": failure_shape(&error) },
|
||||
});
|
||||
}
|
||||
};
|
||||
let (children, terminator) = enumerate_children(&walker, &root);
|
||||
let exhaustion = terminator.as_ref().map(failure_shape);
|
||||
let secure = password_leak_report(&walker, &root);
|
||||
let retained = last_descendant(&walker, &root);
|
||||
let hosted = json!({
|
||||
"hosted": true,
|
||||
"root_resolved": true,
|
||||
"direct_children_found": children.len(),
|
||||
"descendants_found": count_descendants(&walker, &root, 0),
|
||||
});
|
||||
|
||||
let _ = host.kill();
|
||||
let _ = host.wait();
|
||||
thread::sleep(Duration::from_millis(750));
|
||||
|
||||
let forced_first_child = walker.get_first_child(&retained).err();
|
||||
let forced_sibling = walker.get_next_sibling(&retained).err();
|
||||
let stale_root = automation
|
||||
.element_from_handle(Handle::from(host_handle))
|
||||
.err();
|
||||
|
||||
json!({
|
||||
"self_created_window": local,
|
||||
"child_process": hosted,
|
||||
"exhaustion": exhaustion,
|
||||
"secure_field": secure,
|
||||
"forced_failure": {
|
||||
"get_first_child": forced_first_child.as_ref().map(failure_shape),
|
||||
"get_next_sibling": forced_sibling.as_ref().map(failure_shape),
|
||||
"element_from_handle": stale_root.as_ref().map(failure_shape),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn measure() -> Value {
|
||||
json!({ "skipped": "this probe measures the Windows UI Automation runtime" })
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if env::args().any(|argument| argument == HOST_FLAG) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let (sender, receiver) = channel();
|
||||
thread::spawn(move || {
|
||||
if let Ok(Ok(handle)) = receiver.recv_timeout(HOST_READY_TIMEOUT) {
|
||||
println!("{HOST_HANDLE_PREFIX}{handle}");
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
});
|
||||
win::host_window("AgentDesktopProbeHost", SECRET_MARKER, sender);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let document = json!({
|
||||
"probe": "14-ci-capability",
|
||||
"stack": "uia3-com",
|
||||
"uiautomation_version": option_env!("PROBE_UIAUTOMATION_VERSION").unwrap_or("unrecorded"),
|
||||
"measurements": measure(),
|
||||
});
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&document).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
149
probes/windows/14-ci-capability/probe_window.rs
Normal file
149
probes/windows/14-ci-capability/probe_window.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
//! Win32 window creation and message pump for the 2.2 capability probe.
|
||||
//!
|
||||
//! Split from `probe.rs` to keep both files inside the repository's 400-line
|
||||
//! source cap.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, GetWindowRect, IDC_ARROW,
|
||||
IsWindowVisible, LoadCursorW, MSG, PostQuitMessage, RegisterClassExW, SW_SHOWNOACTIVATE,
|
||||
SetWindowTextW, ShowWindow, TranslateMessage, WM_DESTROY, WNDCLASSEXW, WS_CHILD,
|
||||
WS_OVERLAPPEDWINDOW, WS_VISIBLE,
|
||||
};
|
||||
|
||||
const ES_PASSWORD: u32 = 0x0020;
|
||||
const CONTROL_BORDER: u32 = 0x0080_0000;
|
||||
|
||||
pub struct WindowGeometry {
|
||||
pub visible: bool,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
fn wide(text: &str) -> Vec<u16> {
|
||||
text.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
unsafe extern "system" fn window_proc(
|
||||
window: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
if message == WM_DESTROY {
|
||||
unsafe { PostQuitMessage(0) };
|
||||
return 0;
|
||||
}
|
||||
unsafe { DefWindowProcW(window, message, wparam, lparam) }
|
||||
}
|
||||
|
||||
fn register_class(class_name: &str) -> Result<(), String> {
|
||||
let name = wide(class_name);
|
||||
let class = WNDCLASSEXW {
|
||||
cbSize: size_of::<WNDCLASSEXW>() as u32,
|
||||
lpfnWndProc: Some(window_proc),
|
||||
hInstance: unsafe { GetModuleHandleW(std::ptr::null()) },
|
||||
hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) },
|
||||
lpszClassName: name.as_ptr(),
|
||||
..Default::default()
|
||||
};
|
||||
if unsafe { RegisterClassExW(&class) } == 0 {
|
||||
return Err("RegisterClassExW failed".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn child(parent: HWND, class: &str, text: &str, style: u32, top: i32) -> HWND {
|
||||
let class = wide(class);
|
||||
let text = wide(text);
|
||||
unsafe {
|
||||
CreateWindowExW(
|
||||
0,
|
||||
class.as_ptr(),
|
||||
text.as_ptr(),
|
||||
WS_CHILD | WS_VISIBLE | style,
|
||||
8,
|
||||
top,
|
||||
200,
|
||||
24,
|
||||
parent,
|
||||
std::ptr::null_mut(),
|
||||
GetModuleHandleW(std::ptr::null()),
|
||||
std::ptr::null(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the fixture window on the calling thread and pumps until it is
|
||||
/// destroyed. The caller must be a thread that does nothing else, because
|
||||
/// `ElementFromHandle` sends `WM_GETOBJECT` and blocks until this pump
|
||||
/// dispatches it.
|
||||
pub fn host_window(class_name: &str, secret: &str, ready: Sender<Result<isize, String>>) {
|
||||
if let Err(error) = register_class(class_name) {
|
||||
let _ = ready.send(Err(error));
|
||||
return;
|
||||
}
|
||||
let name = wide(class_name);
|
||||
let title = wide("agent-desktop probe fixture");
|
||||
let window = unsafe {
|
||||
CreateWindowExW(
|
||||
0,
|
||||
name.as_ptr(),
|
||||
title.as_ptr(),
|
||||
WS_OVERLAPPEDWINDOW,
|
||||
2000,
|
||||
2000,
|
||||
420,
|
||||
320,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
GetModuleHandleW(std::ptr::null()),
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if window.is_null() {
|
||||
let _ = ready.send(Err("CreateWindowExW failed".into()));
|
||||
return;
|
||||
}
|
||||
child(window, "BUTTON", "probe-button", CONTROL_BORDER, 8);
|
||||
child(window, "STATIC", "probe-static", 0, 40);
|
||||
child(window, "EDIT", "probe-edit", CONTROL_BORDER, 72);
|
||||
let password = child(window, "EDIT", "", CONTROL_BORDER | ES_PASSWORD, 104);
|
||||
let secret = wide(secret);
|
||||
unsafe { SetWindowTextW(password, secret.as_ptr()) };
|
||||
unsafe { ShowWindow(window, SW_SHOWNOACTIVATE) };
|
||||
let _ = ready.send(Ok(window as isize));
|
||||
let mut message = MSG::default();
|
||||
while unsafe { GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) } > 0 {
|
||||
unsafe { TranslateMessage(&message) };
|
||||
unsafe { DispatchMessageW(&message) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins the calling thread to the multithreaded apartment, the
|
||||
/// precondition `UIAutomation::new_direct()` asserts rather than
|
||||
/// establishes. Sub-phase 2.1 owns this step in the product.
|
||||
pub fn join_multithreaded_apartment() -> i32 {
|
||||
use windows_sys::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx};
|
||||
unsafe { CoInitializeEx(std::ptr::null(), COINIT_MULTITHREADED as u32) }
|
||||
}
|
||||
|
||||
pub fn geometry(handle: isize) -> WindowGeometry {
|
||||
let window = handle as *mut c_void;
|
||||
let mut rect = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
};
|
||||
let read = unsafe { GetWindowRect(window, &mut rect) };
|
||||
WindowGeometry {
|
||||
visible: unsafe { IsWindowVisible(window) } != 0,
|
||||
width: if read != 0 { rect.right - rect.left } else { 0 },
|
||||
height: if read != 0 { rect.bottom - rect.top } else { 0 },
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,30 @@ Because there is exactly one environment (KTD2), the `scope` column is doing rea
|
|||
| A11-3 | `12-private-file-io.ps1` | n/a | api-contract | 2.1 requirement: no locality inference from `FileRemoteProtocolInfo` | the API **does** distinguish: 0 of 6 local targets and 3 of 3 remote targets return data. It signals local by **failing** with `ERROR_INVALID_PARAMETER (87)` rather than by returning a local protocol value; remote returns full SMB 3.1.1 data, `Protocol 0x00020000`, `WNNC_NET_SMB`, flags LOOPBACK plus MUTUAL_AUTH. The real hazard is ambiguity, not unreliability: an out-of-range info class, tested with 55, returns that same 87 on all 9 targets, while the control class 0 succeeds on all 9. The correct class constant is `FileRemoteProtocolInfo = 13` | CONTRADICTS | the inference is sound once disambiguated. Corrected in place: 2.1 may read 87 as a locality signal only behind a control call on a known-good class, so a wrong class constant cannot masquerade as a local volume |
|
||||
| A11-4 | `12-private-file-io.ps1` | n/a | app/provider | 2.1 requirement: an ancestor-versus-leaf validation contract decided deliberately against the unix leaf-only rule | neither leaf carries an explicit ACE: the plain leaf has 0 explicit and 3 inherited ACEs, the protected leaf 0 explicit and 1 inherited, and both report all-ACEs-inherited. The restriction is authored at the nearest protected ancestor - the `PAI` protected parent for one, the user profile directory for the other. Walking the full chain, the only permissive ancestor is the volume root granting write to `BUILTIN\Usuarios`; every intermediate directory and both leaves have zero untrusted write grants | NEW-EDGE | a leaf-only check is structurally blind here, because on Windows a leaf's ACL is a projection of an ancestor decision. 2.1 must decide explicitly: walk to the nearest `InheritanceProtected` ancestor and validate there, or diverge from unix leaf-only parity on the record. The capture supplies the evidence; the decision is 2.1's and is not pre-empted here |
|
||||
| A11-5 | `./target/release/agent-desktop.exe status` | n/a | api-contract | R13's framing that `FileRemoteProtocolInfo` failed closed on plain local NTFS, which is what broke `status` | that breakage is **historical**. The Win32 private-file layer was deleted from core in PR #106 and `status` now returns `ok:true` at exit 0, see R6-4; a repo-wide grep for the message string returns nothing | NEW-EDGE | A11-3 measures the OS primitive because 2.1 rebuilds from scratch and needs the measurement, not because anything is broken today. No row in this ledger claims `status` is currently broken |
|
||||
## Area 14 - CI capability, the end-of-list discriminator and the secure-field gate (sub-phase 2.2)
|
||||
|
||||
Added by sub-phase 2.2's unit U1. Every row was measured twice: on this ledger's dev box
|
||||
(Server 2019, build 17763) and on the hosted `windows-latest` runner (Server 2025, build
|
||||
26100, image `win25-vs2026` 20260714.173.1) through
|
||||
`.github/workflows/windows-capability-probe.yml`. Re-run either with
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -File .\14-ci-capability\probe.ps1`.
|
||||
Captures are `14-ci-capability/captures/{session,uia-capability}-{devbox,ci}.json`.
|
||||
|
||||
| id | script | stack | scope | phases.md expectation | observed | verdict | action |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| A14-1 | `14-ci-capability/probe.ps1` | n/a | app/provider | 2.2's own risk register inferred, from third-party observations captured on **windows-2022**, that a hosted runner may present no interactive desktop session, which would put every fixture-backed assertion behind 2.12's self-hosted runner | `windows-latest` resolves to Server 2025 build 26100, image `win25-vs2026` 20260714.173.1. `qwinsta` reports `>console` at id 2 **Active** alongside two Listen entries; the probe process runs in session 2, `[Environment]::UserInteractive` is true, the window station is `WinSta0` and the desktop is `Default`. `SESSIONNAME` is unset, so a probe keying off that variable alone would have concluded the opposite | CONTRADICTS | the inference is replaced by measurement: 2.2's fixture-backed gates land on the hosted runner in this PR rather than deferring to 2.12. Environment dependency recorded explicitly - this is one image on one date, and the runner image is not a product contract. 2.12's self-hosted-runner justification is untouched, because it rests on presenting real applications on a representative shell, not on session existence |
|
||||
| A14-2 | `14-ci-capability/probe.rs` | uia3-com | app/provider | KTD8 asserts that a window the test process creates itself, hosted out-of-process, is walkable - the single fact U4's whole strategy rests on, with no prior measurement behind it | a top-level window created by the probe with `WS_OVERLAPPEDWINDOW`, shown `SW_SHOWNOACTIVATE` at an off-screen origin with a non-zero rect, is `IsWindowVisible` true and resolves through `ElementFromHandle` from a second MTA thread that owns no windows. Byte-identical results in both environments and in both hosting modes: 5 direct children and 10 descendants, in-process and across a real process boundary | CONFIRMS | U4 builds the fixture as planned and U6/U7 assert against the child-process variant. Off-screen placement is sufficient; `SW_HIDE` and a message-only window were not exercised and remain unsupported by rule, per `HwndProxyElementProvider`'s visibility and zero-area exclusions |
|
||||
| A14-3 | `14-ci-capability/probe.rs` | uia3-com | api-contract | KTD3 reasons - but explicitly declines to assume - that `uiautomation` signals end-of-siblings as `Err` whose `result()` is `None`, and warns that an inverted discriminator would classify every real failure as benign and report a truncated tree as complete | at sibling exhaustion `UITreeWalker::get_next_sibling` returns `Err` with `code() == 0` and `result() == None`, identically on build 17763 and build 26100. The chain behind it is source-verified: windows-rs `Type::from_abi` returns `Error::empty()` for a null interface out-param, `windows-result` 0.4.1 maps that to `HRESULT(0)`, and `uiautomation`'s `From<windows::core::Error>` stores `code: e.code().0` verbatim, so `result()` is `None` exactly when the stored code is non-negative | CONFIRMS | the discriminator is not inverted. 2.2's walker classifies exhaustion as `result().is_none()`, and U6's fake enumerators are built from this measured pair rather than from a reading of the crate |
|
||||
| A14-4 | `14-ci-capability/probe.rs` | uia3-com | api-contract | nothing - 2.0 never killed a target mid-walk, and 2.2 lists mid-walk process death as one of five scope items with zero measured evidence | with the host process killed and its elements retained, the two enumeration axes disagree. `get_first_child` returns `0x80004005` `E_FAIL` with `result()` `Some`, so descent surfaces the death. `get_next_sibling` on the same dead element returns `code() == 0` and `result() == None` - **the exact pair a live provider returns at end-of-list**. `ElementFromHandle` on the destroyed handle returns `0x80040201` `UIA_E_ELEMENTNOTAVAILABLE`. Identical on both builds | NEW-EDGE | the sibling axis alone cannot distinguish a provider that has gone away from a sibling list that has ended, so no walker can detect mid-walk process death from the sibling terminator. 2.2 classifies on the measured pair and records the consequence: a subtree whose provider dies part-way through its sibling list is reported complete with fewer siblings than exist. The descent axis does surface it, and the deadline bounds the rest. A sub-phase that needs positive mid-walk liveness must add an independent check - re-resolving the root, or a process-liveness read - rather than trusting the terminator |
|
||||
| A14-5 | `14-ci-capability/probe.rs` | uia3-com | api-contract | 2.2 R4 requires U1's measured HRESULT encoded into the `WINDOW_NOT_FOUND` versus `ELEMENT_NOT_FOUND` mapping of the production root resolver | `ElementFromHandle` against a window handle whose process has exited returns `0x80040201` `UIA_E_ELEMENTNOTAVAILABLE`, not `E_INVALIDARG` and not a crate sentinel | CONFIRMS | `root_from_hwnd` maps `UIA_E_ELEMENTNOTAVAILABLE` and the `ERR_NOTFOUND` sentinel to `WINDOW_NOT_FOUND` at the root-resolution site, while the shared classifier keeps the same HRESULT as `STALE_REF` everywhere else, where it genuinely means the element rather than the window went away |
|
||||
| A14-6 | `14-ci-capability/probe.rs` | uia3-com | app/provider | KTD13 records the claim that "UIA already refuses `ValuePattern.Value`" as covering one pattern, saying nothing about `Name`, `HelpText` or `LegacyIAccessible.Value`, and moves it into U1's measurement list | a standard `EDIT` carrying `ES_PASSWORD`, with a 15-character marker written through `SetWindowTextW`, reports `IsPassword` true. `ValueValue`, `LegacyIAccessibleValue` and `HelpText` all come back present-but-empty - `VT_BSTR`, zero characters - and `Name` is a 12-character string that is not the marker. The marker appears in none of the four reads, on either build | CONFIRMS | UIA does not leak this control's content through any of the four. The gate still ships in 2.2 per KTD13, and the reason is scope rather than distrust: this measures one control class from one in-box provider on two builds, and says nothing about a custom provider that sets `IsPassword` and populates `Name` itself. `IsPassword` rides in the same cache request as the properties it gates, so the gate costs no extra round trip |
|
||||
| A14-7 | `cargo check -p agent-desktop-windows --all-targets` | n/a | api-contract | 2.2 U2 plans to pin `uiautomation`'s features deliberately, recording that the defaults are `control` plus `input` and that the non-default features belong to later sub-phases | `control` alone does not compile. `uiautomation` 0.25.0's `core.rs:38` imports `crate::inputs::MouseButton` unconditionally while `lib.rs:12` gates the `inputs` module behind the `input` feature, so `default-features = false, features = ["control"]` fails at `unresolved import` before any product code is reached | NEW-EDGE | 2.2 pins the crate defaults explicitly - `default-features = false` with `control` and `input` - rather than narrowing to `control`. The pin is still deliberate: `process`, `clipboard`, `screenshot`, `event` and `dialog` stay off, and a future change to the crate's default set cannot silently widen the surface || A14-8 | `cargo test -p agent-desktop-windows --lib -- tree::fixture` | uia3-com | app/provider | A1-2 observed, on classic Notepad through the **managed** stack, that `IsOffscreen` is false on every node of a minimized window including the empty-rect top level | the two-shape geometry degeneration reproduces exactly on 2.2's own fixture through the **COM** stack - the minimized top level reports an empty rectangle while all five descendants keep real 200x24 extents anchored at the off-screen origin - and the descendant count is unchanged across minimize, so Windows Engineering Invariant 10 holds. `IsOffscreen` does **not** reproduce: the minimized top level reports **true** while every descendant reports **false** | CONTRADICTS | A1-2's `IsOffscreen`-false clause does not travel off classic Notepad and the managed stack, and its own action clause is strengthened rather than weakened: not only is `IsOffscreen` insufficient alone, it disagrees with itself **within a single window**, so a container reporting offscreen says nothing about descendants that are not. 2.7 must derive offscreen evidence per element and must not propagate a container's value to its subtree. 2.2's fixture test asserts the completeness and geometry rule and asserts no `IsOffscreen` value |
|
||||
| A14-9 | `cargo test -p agent-desktop-windows --lib -- tree::properties` | uia3-com | api-contract | A14-4 established that the sibling axis cannot distinguish a dead provider from end-of-list; nothing measured what a *property* read does once the target process has exited | it does not fail either. With the host process killed and its elements retained, `GetCurrentPropertyValue` for `ClassName`, `Name` and `Value` all return `S_OK` carrying an empty `VT_BSTR`, with **no error on any of the three** - the client-side HWND proxy answers locally rather than attempting a call the dead target cannot serve. **The two environments disagree**, which is why this row records both: on build 17763 the reads succeed empty as described, while on the Server 2025 CI runner the same reads fail and classify `Unknown`. So a dead provider is sometimes indistinguishable from an empty one and sometimes from an unreadable one - never from a live one | NEW-EDGE | process death is invisible on the property axis as well as the sibling axis, so neither can carry liveness. Only descent (`get_first_child`, A14-4) surfaces it. The consequence for 2.2 is a rule rather than a detection: a provider that went away must never be reported `Absent`, because `Absent` is a legitimate answer that satisfies completeness gating, and a dead target must not be able to satisfy `EvidenceRequirements` it never answered. Because the outcome is environment-dependent, the rule is enforced on **both** shapes: the secure-field gate withholds only content that was actually read, and never rewrites a failed read into `Absent`. The read path asserts that rule; it does not claim to detect death |
|
||||
| A14-10 | `cargo test -p agent-desktop-windows --lib` | n/a | api-contract | 2.1's `ensure_owned_process_mta_and_dpi` guards `CoInitializeEx` behind a process-wide `OnceLock`, with its own doc-comment recording that this is sound "only because the CLI calls this once from its main thread before any COM work" | the caveat is load-bearing and 2.2 is the first consumer to reach it. `CoInitializeEx` is thread-local while the guard is process-wide, so in a multi-threaded test binary exactly one thread joins the apartment and every other thread's `CoCreateInstance` returns `CO_E_NOTINITIALIZED`; observed as 17 of 62 tree tests failing under default test parallelism and 0 of 62 with `--test-threads=1` | NEW-EDGE | no live defect - the CLI is single-threaded at bootstrap and the cdylib already uses the process-wide `CoIncrementMTAUsage` path. 2.2's tests use `ensure_hosted_library_mta_and_dpi`, which is the semantically correct primitive for threads this product does not own. Recorded because the next consumer that reaches COM from a worker thread - Phase 5's daemon, or any 2.4+ code that walks off the main thread - hits the same wall, and the failure is a confusing `CO_E_NOTINITIALIZED` from a process that did bootstrap successfully |
|
||||
| A14-11 | `cargo test -p agent-desktop-windows --lib -- tree::automation` | uia3-com | api-contract | 2.2's risk register records that UIA has no per-element messaging timeout, that `ConnectionTimeout` and `TransactionTimeout` are not documented to bound the `WM_GETOBJECT` `SendMessage` that `ElementFromHandle` issues, and that "whether a non-pumping target produces a clean timeout or a hang is unverified, and the fixture cannot produce the condition" | the fixture **can** produce it: `CreateWindowExW` dispatches `WM_CREATE` inline, so a thread can own a live, visible, non-zero-rect window and then never dispatch again. Against that window `ElementFromHandle` produces **neither** a clean timeout nor a bounded failure - it blocks. Measured by removing the mitigation below: the resolver did not return within the test's 30 s watchdog and the case took 59.21 s to fail, against 2.01 s with it. A `Deadline` checked before and after the call cannot interrupt it, because the block is inside the call | NEW-EDGE | the question is closed: it is a hang, not a timeout. `root_from_hwnd` now asks `SendMessageTimeoutW(WM_NULL, SMTO_ABORTIFHUNG)` first, so a target that is already hung becomes a structured `APP_UNRESPONSIVE`, and asks `IsWindow` before that so a destroyed handle stays `WINDOW_NOT_FOUND` per A14-5 rather than being reported as hung. This is a mitigation, not a guarantee - a target that stops pumping between the probe and the call still blocks, and bounding that needs the call issued on a thread the caller can abandon. 2.4 owns the snapshot path that would need it |
|
||||
| A14-12 | `cargo test -p agent-desktop-windows --lib -- tree::automation` | uia3-com | api-contract | 2.2's risk register names `ConnectionTimeout` (2 s) and `TransactionTimeout` (20 s) as UIA's own bounds and records that they are "not documented to bound the `WM_GETOBJECT` `SendMessage` that `ElementFromHandle` issues" | **they do bound it** - the obstacle was reaching them, not their effect. `uiautomation::UIAutomation::new_direct()` is `CoCreateInstance(&CUIAutomation, ...)`, and on build 17763 that object returns `E_NOINTERFACE` (`0x80004002`) for `IUIAutomation2`, where the setters live, so calls through the crate's own client carry no timeout at all. Built instead from `CUIAutomation8` with `SetConnectionTimeout(2000)`, the identical call against a window whose thread owns it but never dispatches returns `UIA_E_TIMEOUT` (`0x80131505`) in **1.02 s**; through the crate's client the same call did not return inside a 30 s watchdog and the case took 59.09 s to fail | CONTRADICTS | the resolver's remaining hang was closable and is closed. The client is constructed from `CUIAutomation8` by direct `CoCreateInstance`, which keeps every property `new_direct()` was chosen for - it never calls `CoInitializeEx`, so it works inside an STA host and leaks no initialization count in a long-lived process - and differs only in CLSID. There is deliberately **no fallback** to the unbounded client: a fallback would trade the hang guarantee for availability on a platform that cannot occur, since `CUIAutomation8` has shipped since Windows 8, the product floor is Windows 10 1809, and it is measured present on both build 17763 and the Server 2025 CI image. A client whose calls cannot be bounded is refused outright. **This is a deviation from the Definition of Done's "constructed with `new_direct()` only" and is flagged for the owner rather than assumed**; KTD1's own prohibition is on `UIAutomation::new()`, which is still never called, and both of KTD1's stated reasons are preserved. A test pins that the crate's client lacks the interface, which is the whole reason it is not used |
|
||||
| A14-13 | `cargo test -p agent-desktop-windows --lib -- tree::automation` | uia3-com | api-contract | nothing - 2.0 never used UI Automation from more than one thread, and 2.2's threading work concerned which apartment a thread joins, not what happens when several threads reach the client core at once | UI Automation's client core initialises lazily on first use, and that initialisation is **not re-entrant**. Four threads that had each successfully created their own client, then called `get_root_element` concurrently, produced `E_FAIL` (`0x80004005`) on all but one, with the message `Re-Entrant CheckInit() call, aborting.` - **immediately**, in 0.02 s, not as a timeout. Creating the client does not trigger the initialisation; the first *call* does | NEW-EDGE | first touch must be serialised process-wide, and the serialising thread must make one call while holding the lock, because client construction alone does not warm the core. 2.2's client accessor does this. Any multi-threaded consumer hits it otherwise, and the symptom is misleading: a client that constructed successfully fails its first call with a generic `E_FAIL`. Phase 5's daemon and any future parallel walk inherit this. Verified by removing the guard and observing the race return |
|
||||
|
||||
## Session evidence (R6)
|
||||
|
||||
Rows already executed this session rather than produced by a probe script, carried as
|
||||
|
|
@ -231,31 +255,35 @@ The five `CONTRADICTS` rows are A1-5, A6-1, A8-3, A11-1 and A11-3; each appears
|
|||
| H16 | `@@ -974,2 +977,2 @@` 2.1 records the pins and scopes the embargo to `uiautomation`/`windows-capture`, and the private-file requirements with the seam stated as a constraint | R6-11, A11-1, A11-3 (research: every private-artifact write site is in core with no adapter handle, and core may not depend on the platform crate) |
|
||||
| H17 | `@@ -977 +980 @@` 2.1 key APIs gain `CoIncrementMTAUsage`, `ReplaceFileW` and `GetFileInformationByHandleEx` | A11-1, A11-3 |
|
||||
| H18 | `@@ -981 +984 @@` 2.1 exit criteria: package scope, adapter-backed commands and portable private-file assertions, with no runner or RDP measurement left in the gate | C-12, R6-3, R6-5, A11-2, A11-4, C-11 |
|
||||
| H19 | `@@ -996 +999 @@` 2.2 key APIs, `uiautomation` 0.25+ constructed with `new_direct()` | R6-11, C-2 (research: the crate's own two constructor doc strings) |
|
||||
| H19 | `@@ -996 +999 @@` 2.2 key APIs, `uiautomation` 0.25+ constructed with `new_direct()`, and the reason for it corrected | R6-11, C-2 (research: the crate's own two constructor doc strings, then the 0.25.0 source itself - `new()` proceeds on any non-negative HRESULT, so it reads `S_FALSE` as success on an MTA thread and leaks one initialization count, and fails `RPC_E_CHANGED_MODE` on an STA host thread) |
|
||||
| H20 | `@@ -1032 +1035 @@` 2.4 Chromium detection requires a settle before judging thinness | A1-5, C-4 |
|
||||
| H21 | `@@ -1102,2 +1105,2 @@` API mapping table, tree-root pin plus `new_direct()`, and the `CacheRequest` phase split | R6-11, A6-1, A6-2, C-2 |
|
||||
| H22 | `@@ -1211 +1214,4 @@` 2.12 registers the self-hosted interactive runner, hardens that registration for a public repository, and owns the deferred-row closure | A10-2 (research: GitHub's own guidance against self-hosted runners on public repositories) |
|
||||
| H23 | `@@ -1217 +1223 @@` 2.12 exit criteria gain the runner, its written hardening policy and the RDP measurement | A10-2 |
|
||||
| H24 | `@@ -1219 +1225 @@` 2.12 estimate covers runner registration alongside the fixture and harness | A10-2 |
|
||||
| H25 | `@@ -1227 +1233 @@` npm postinstall gains `win32-arm64` | C-6 |
|
||||
| H26 | `@@ -1240 +1246 @@` 2.14 title loses the stretch qualifier | A10-2 |
|
||||
| H27 | `@@ -1244 +1250 @@` 2.14 ships before the 2.15 merge | A10-2 |
|
||||
| H28 | `@@ -1261 +1267 @@` tray command table, overflow flyout class | C-5 |
|
||||
| H29 | `@@ -1280 +1286 @@` 2.14 list-items bullet, overflow flyout class | C-5 |
|
||||
| H30 | `@@ -1298 +1304 @@` 2.15 depends on all of 2.0 through 2.14 | A10-2 |
|
||||
| H31 | `@@ -1306,2 +1312,2 @@` OS floors, Windows 10 servicing reality and the WGC feature floors | C-7, C-8, A10-5 |
|
||||
| H32 | `@@ -1316,3 +1322,3 @@` new-dependency pin table | R6-11, C-1, C-2, C-3 |
|
||||
| H33 | `@@ -1322 +1328 @@` pin re-verification note | R6-11 |
|
||||
| H34 | `@@ -1335,3 +1341,3 @@` `Cargo.toml` snippet: `uiautomation = "0.25"`, the five `windows` features 2.1's own scope needs, and `windows-capture = "2.0.0"` | R6-11, C-1, C-2, C-3 (research: all five feature gates verified present in `windows` 0.62.2 and `windows-sys` 0.61.2 on docs.rs) |
|
||||
| H35 | `@@ -1389 +1395 @@` docs checklist, Windows permissions and Chromium 138 | C-4, A1-5 |
|
||||
| H36 | `@@ -1716 +1722 @@` 3.14 title loses the stretch qualifier | A10-2 |
|
||||
| H37 | `@@ -1720 +1726 @@` 3.14 ships before the 3.15 merge | A10-2 |
|
||||
| H38 | `@@ -1768 +1774 @@` 3.15 depends on all of 3.0 through 3.14 | A10-2 |
|
||||
| H39 | `@@ -2476,3 +2482,4 @@` CI evolution table, v0.6.0 row plus the Phase 2 and 3 rows, with runner registration named at 2.12 | C-14, R6-1, R6-6, A10-2 |
|
||||
| H40 | `@@ -2482 +2489 @@` runner enforcement: package scope, and clippy, core isolation and the size cap stated as macOS-only today | C-12, C-13, R6-3, R6-6, R6-7 |
|
||||
| H41 | `@@ -2493,3 +2500,3 @@` new-dependencies summary table | R6-11, C-1, C-2, C-3 |
|
||||
| H42 | `@@ -2536 +2543 @@` R2 risk row, settle before judging a Chromium tree thin | A1-5, C-4 |
|
||||
| H43 | `@@ -2546,2 +2553,2 @@` R12 risk row, the `tscon` workaround is owed by the sub-phase that registers the runner, and R13, handler removal cost is window churn rather than the barrier | A10-2, A8-3 |
|
||||
| H21 | `@@ -1102,2 +1106,2 @@` API mapping table, tree-root pin plus `new_direct()` and its corrected reason, and the `CacheRequest` phase split | R6-11, A6-1, A6-2, C-2 (research: `uiautomation` 0.25.0 source, the same correction H19 carries - the table stated the disproved reason in a second place) |
|
||||
| H22 | `@@ -1211 +1215,5 @@` 2.12 registers the self-hosted interactive runner, hardens that registration for a public repository, owns the deferred-row closure, and records the measured hosted-runner session without weakening the justification | A10-2, A14-1 (research: GitHub's own guidance against self-hosted runners on public repositories, and Microsoft's contrary Azure Pipelines UI-testing guidance) |
|
||||
| H23 | `@@ -1217 +1225 @@` 2.12 exit criteria gain the runner, its written hardening policy and the RDP measurement | A10-2 |
|
||||
| H24 | `@@ -1219 +1227 @@` 2.12 estimate covers runner registration alongside the fixture and harness | A10-2 |
|
||||
| H25 | `@@ -1227 +1235 @@` npm postinstall gains `win32-arm64` | C-6 |
|
||||
| H26 | `@@ -1240 +1248 @@` 2.14 title loses the stretch qualifier | A10-2 |
|
||||
| H27 | `@@ -1244 +1252 @@` 2.14 ships before the 2.15 merge | A10-2 |
|
||||
| H28 | `@@ -1261 +1269 @@` tray command table, overflow flyout class | C-5 |
|
||||
| H29 | `@@ -1280 +1288 @@` 2.14 list-items bullet, overflow flyout class | C-5 |
|
||||
| H30 | `@@ -1298 +1306 @@` 2.15 depends on all of 2.0 through 2.14 | A10-2 |
|
||||
| H31 | `@@ -1306,2 +1314,2 @@` OS floors, Windows 10 servicing reality and the WGC feature floors | C-7, C-8, A10-5 |
|
||||
| H32 | `@@ -1316,3 +1324,3 @@` new-dependency pin table | R6-11, C-1, C-2, C-3 |
|
||||
| H33 | `@@ -1322 +1330 @@` pin re-verification note | R6-11 |
|
||||
| H34 | `@@ -1335,3 +1343,3 @@` `Cargo.toml` snippet: `uiautomation = "0.25"`, the five `windows` features 2.1's own scope needs, and `windows-capture = "2.0.0"` | R6-11, C-1, C-2, C-3 (research: all five feature gates verified present in `windows` 0.62.2 and `windows-sys` 0.61.2 on docs.rs) |
|
||||
| H35 | `@@ -1389 +1397 @@` docs checklist, Windows permissions and Chromium 138 | C-4, A1-5 |
|
||||
| H36 | `@@ -1716 +1724 @@` 3.14 title loses the stretch qualifier | A10-2 |
|
||||
| H37 | `@@ -1720 +1728 @@` 3.14 ships before the 3.15 merge | A10-2 |
|
||||
| H38 | `@@ -1768 +1776 @@` 3.15 depends on all of 3.0 through 3.14 | A10-2 |
|
||||
| H39 | `@@ -2476,3 +2484,4 @@` CI evolution table, v0.6.0 row plus the Phase 2 and 3 rows, with runner registration named at 2.12 | C-14, R6-1, R6-6, A10-2 |
|
||||
| H40 | `@@ -2482 +2491 @@` runner enforcement: package scope, and clippy, core isolation and the size cap stated as macOS-only today | C-12, C-13, R6-3, R6-6, R6-7 |
|
||||
| H41 | `@@ -2493,3 +2502,3 @@` new-dependencies summary table | R6-11, C-1, C-2, C-3 |
|
||||
| H42 | `@@ -2536 +2545 @@` R2 risk row, settle before judging a Chromium tree thin | A1-5, C-4 |
|
||||
| H43 | `@@ -2546,2 +2555,2 @@` R12 risk row, the `tscon` workaround is owed by the sub-phase that registers the runner, and R13, handler removal cost is window churn rather than the barrier | A10-2, A8-3 |
|
||||
| H44 | `@@ -992,2 +995,2 @@` 2.2 scope: the cycle-guard rationale, and `CacheRequest` batching stated as conditional with no node-count arm | A6-1, A6-2 (research: 2.0 measured nothing about UIA element-identity reuse and neither 2.0 walker calls `GetParent`, so the imported macOS pointer-reuse rationale had no backing) |
|
||||
| H45 | `@@ -1000 +1003 @@` 2.2 exit criteria: the dump binary batches by provider class, and the cache unit test asserts equality rather than speed | A6-1, A6-2 |
|
||||
| H46 | `@@ -1041 +1045 @@` 2.4 exit criteria restated rule-shaped, with named apps and fixed ref thresholds removed and an explicit skip-with-reason | A6-2, A2-4, A1-1, C-9, C-11 (research: the probe corpus's own KTD7 scope rule, which keeps an `app/provider` row from travelling) |
|
||||
| H48 | `@@ -1035,0 +1039 @@` 2.4 scope records what 2.2 hands over unresolved: mid-walk process death is invisible on both the sibling and property axes, and the hang bound is the client's connection timeout rather than the operation deadline | A14-4, A14-9, A14-11 |
|
||||
|
||||
## Completeness self-check
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
# Windows platform exploration probes (sub-phase 2.0)
|
||||
# Windows platform exploration probes (sub-phases 2.0 and later)
|
||||
|
||||
Evidence-gathering scripts for the Windows adapter. They observe the real OS and write
|
||||
bounded, redacted captures that the `FINDINGS.md` ledger cites. Nothing here is product
|
||||
code: no Rust, no CI, no workspace dependencies.
|
||||
code, and nothing here is a workspace member.
|
||||
|
||||
Sub-phase 2.0 (`00-*.ps1` through `13-*.ps1`) is PowerShell only. Later sub-phases extend
|
||||
the corpus where a question can only be answered against the crate the adapter actually
|
||||
ships or against a runner this box is not: `14-ci-capability/` adds a standalone Rust
|
||||
probe, built in a scratch directory outside the workspace, and
|
||||
`.github/workflows/windows-capability-probe.yml` runs it on `windows-latest`. That
|
||||
workflow is path-filtered to the probe directory and its own file, so it adds no time to
|
||||
the required lanes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue