mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-08-07 14:40:05 +00:00
test: host the uia fixture window in a second process
Sub-phase 2.2 unit U4, plus U1's ledger rows and U9's document corrections. A window the test process creates itself is served by in-process client-side providers, so the failure taxonomy the walker exists to classify is structurally unreachable from it, and a cache policy validated there is validated against exactly the provider class the policy says to skip. The default fixture therefore re-executes the test binary against one ignored entry point and hosts the window in that second process; the in-process variant is retained only for teardown and concurrency tests. UI Automation calls are issued only from threads that own no fixture window, because ElementFromHandle sends WM_GETOBJECT and a cross-thread SendMessage blocks until the receiving thread pumps. The class name is unique per fixture so parallel tests cannot collide on ERROR_CLASS_ALREADY_EXISTS, and the host process carries a watchdog so a crashed parent cannot strand a window on a shared runner. The ledger gains area 14. Two rows change what was planned. A14-4: with the host process killed, get_first_child surfaces E_FAIL while get_next_sibling returns the exact pair a live provider returns at end-of-list, so no walker can detect mid-walk process death from the sibling terminator. A14-8: the minimized-window geometry rule reproduces on the COM stack, but A1-2's IsOffscreen-false clause does not - the minimized top level reports true while every descendant reports false, so a container's value says nothing about its subtree. phases.md gains the five corrections U9 owns. The new_direct() reason was wrong in two places rather than one; both now carry the source-verified reason. The hunk index moves with them, 43 to 46.
This commit is contained in:
parent
77178e0f83
commit
96ecaf4e6d
13 changed files with 1416 additions and 176 deletions
|
|
@ -23,7 +23,10 @@ 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",
|
||||
] }
|
||||
|
||||
|
|
|
|||
182
crates/windows/src/tree/fixture.rs
Normal file
182
crates/windows/src/tree/fixture.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
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);
|
||||
|
||||
/// 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();
|
||||
spawn(move || {
|
||||
if let Ok(Ok(handle)) = receiver.recv_timeout(READY_TIMEOUT) {
|
||||
println!("{HANDLE_PREFIX}{handle}");
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
match receiver.recv_timeout(READY_TIMEOUT) {
|
||||
Ok(handle) if handle != 0 => Ok(Self {
|
||||
child: Some(child),
|
||||
handle,
|
||||
}),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
Err(String::from(
|
||||
"the fixture host never reported a window 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,
|
||||
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)
|
||||
});
|
||||
match receiver.recv_timeout(READY_TIMEOUT) {
|
||||
Ok(Ok(handle)) => Ok(Self {
|
||||
handle,
|
||||
class_name,
|
||||
pump: Some(pump),
|
||||
}),
|
||||
Ok(Err(error)) => Err(error),
|
||||
Err(_) => Err(String::from("the fixture window never became ready")),
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if let Some(pump) = self.pump.take() {
|
||||
let _ = pump.join();
|
||||
}
|
||||
fixture_window::destroy_window(self.handle);
|
||||
fixture_window::unregister_class(&self.class_name);
|
||||
}
|
||||
}
|
||||
|
||||
fn host_on_this_thread(class_name: &str, ready: Sender<Result<isize, String>>) {
|
||||
fixture_window::host_window(class_name, ready);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fixture_tests.rs"]
|
||||
mod tests;
|
||||
241
crates/windows/src/tree/fixture_tests.rs
Normal file
241
crates/windows/src/tree/fixture_tests.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
use super::*;
|
||||
use crate::system::com_runtime::ensure_owned_process_mta_and_dpi;
|
||||
use crate::tree::automation::{automation_client, uia_error};
|
||||
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 bootstrap() {
|
||||
ensure_owned_process_mta_and_dpi().expect("2.1 bootstrap establishes the apartment");
|
||||
}
|
||||
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
258
crates/windows/src/tree/fixture_window.rs
Normal file
258
crates/windows/src/tree/fixture_window.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
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::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW, GetWindowRect,
|
||||
IDC_ARROW, IsWindowVisible, LoadCursorW, MSG, PostQuitMessage, RegisterClassExW, SW_MINIMIZE,
|
||||
SW_SHOWNOACTIVATE, SetWindowTextW, ShowWindow, TranslateMessage, UnregisterClassW, WM_CLOSE,
|
||||
WM_DESTROY, WNDCLASSEXW, WS_CHILD, WS_OVERLAPPEDWINDOW, WS_VISIBLE,
|
||||
};
|
||||
|
||||
const ES_PASSWORD: u32 = 0x0020;
|
||||
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,
|
||||
}
|
||||
|
||||
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() {
|
||||
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<isize, 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) };
|
||||
let _ = ready.send(Ok(window as isize));
|
||||
pump_until_destroyed();
|
||||
}
|
||||
|
||||
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() {
|
||||
let mut message = MSG::default();
|
||||
while unsafe { GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) } > 0 {
|
||||
unsafe { TranslateMessage(&message) };
|
||||
unsafe { DispatchMessageW(&message) };
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn close_window(handle: isize) {
|
||||
unsafe {
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW(
|
||||
handle as *mut c_void,
|
||||
WM_CLOSE,
|
||||
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) };
|
||||
}
|
||||
|
||||
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,2 +1,7 @@
|
|||
pub mod automation;
|
||||
pub mod element;
|
||||
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod fixture;
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod fixture_window;
|
||||
|
|
|
|||
|
|
@ -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 with `UIAutomation::new_direct()` — verified against the `uiautomation` 0.25.0 source, `UIAutomation::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 having no `Drop` and the crate never calling `CoUninitialize` — a leak Phase 5's long-lived daemon accumulates — while on any STA host thread it returns `Err(RPC_E_CHANGED_MODE)` outright)
|
||||
|
||||
**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
|
||||
|
||||
|
|
@ -1041,7 +1041,7 @@ Every sub-phase 2.0–2.15 below is held to the same definition of done, stated
|
|||
|
||||
**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 +1102,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 with `UIAutomation::new_direct()`, never `UIAutomation::new()` — verified against `uiautomation` 0.25.0 source, the latter initializes the COM library itself and proceeds on any non-negative HRESULT, so it takes `S_FALSE` as success on an MTA thread and leaks one initialization count per call (no `Drop`, no `CoUninitialize`), and returns `Err(RPC_E_CHANGED_MODE)` on an STA host thread |
|
||||
| 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 +1214,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`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,434 @@
|
|||
---
|
||||
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 with `UIAutomation::new_direct()`, never `UIAutomation::new()`.** (session-settled: user-directed.) Governs R4. See KTD1 for the corrected reason.
|
||||
- **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 for `new_direct()`: 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.
|
||||
|
||||
- **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/>new_direct · 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 calling `UIAutomation::new_direct()`; 2.1's bootstrap already guarantees the precondition, so the accessor asserts rather than establishes it and returns a structured error if COM is uninitialised (`CO_E_NOTINITIALIZED`).
|
||||
**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 with per-node `ControlType`, `ClassName`, `AutomationId`, `Name` **presence only**, bounds, parent index, and `ProviderDescription`. 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 captures committed with variant, build, client stack, and host-data normalisation | 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 with `new_direct()` only, 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 dumps of Notepad and Explorer exist as dev-box evidence, normalised, recording variant, build, and client stack.
|
||||
- `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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -147,6 +147,7 @@ windows-sys = { version = "0.61", features = [
|
|||
$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 {
|
||||
|
|
|
|||
|
|
@ -27,152 +27,8 @@ const WALK_DEPTH_LIMIT: u32 = 12;
|
|||
const HOST_READY_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod win {
|
||||
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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "probe_window.rs"]
|
||||
mod win;
|
||||
|
||||
fn failure_shape(error: &UiaError) -> Value {
|
||||
json!({
|
||||
|
|
|
|||
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,25 @@ 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 |
|
||||
|
||||
## Session evidence (R6)
|
||||
|
||||
Rows already executed this session rather than produced by a probe script, carried as
|
||||
|
|
@ -231,31 +250,34 @@ 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 +1105,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 +1214,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 +1224 @@` 2.12 exit criteria gain the runner, its written hardening policy and the RDP measurement | A10-2 |
|
||||
| H24 | `@@ -1219 +1226 @@` 2.12 estimate covers runner registration alongside the fixture and harness | A10-2 |
|
||||
| H25 | `@@ -1227 +1234 @@` npm postinstall gains `win32-arm64` | C-6 |
|
||||
| H26 | `@@ -1240 +1247 @@` 2.14 title loses the stretch qualifier | A10-2 |
|
||||
| H27 | `@@ -1244 +1251 @@` 2.14 ships before the 2.15 merge | A10-2 |
|
||||
| H28 | `@@ -1261 +1268 @@` tray command table, overflow flyout class | C-5 |
|
||||
| H29 | `@@ -1280 +1287 @@` 2.14 list-items bullet, overflow flyout class | C-5 |
|
||||
| H30 | `@@ -1298 +1305 @@` 2.15 depends on all of 2.0 through 2.14 | A10-2 |
|
||||
| H31 | `@@ -1306,2 +1313,2 @@` OS floors, Windows 10 servicing reality and the WGC feature floors | C-7, C-8, A10-5 |
|
||||
| H32 | `@@ -1316,3 +1323,3 @@` new-dependency pin table | R6-11, C-1, C-2, C-3 |
|
||||
| H33 | `@@ -1322 +1329 @@` pin re-verification note | R6-11 |
|
||||
| H34 | `@@ -1335,3 +1342,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 +1396 @@` docs checklist, Windows permissions and Chromium 138 | C-4, A1-5 |
|
||||
| H36 | `@@ -1716 +1723 @@` 3.14 title loses the stretch qualifier | A10-2 |
|
||||
| H37 | `@@ -1720 +1727 @@` 3.14 ships before the 3.15 merge | A10-2 |
|
||||
| H38 | `@@ -1768 +1775 @@` 3.15 depends on all of 3.0 through 3.14 | A10-2 |
|
||||
| H39 | `@@ -2476,3 +2483,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 +2490 @@` 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 +2501,3 @@` new-dependencies summary table | R6-11, C-1, C-2, C-3 |
|
||||
| H42 | `@@ -2536 +2544 @@` R2 risk row, settle before judging a Chromium tree thin | A1-5, C-4 |
|
||||
| H43 | `@@ -2546,2 +2554,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 +1044 @@` 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) |
|
||||
|
||||
## Completeness self-check
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue